From 5984c58846abd9c9068d67db1da8b119455dc6fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 5 Jun 2026 11:51:44 -0400 Subject: [PATCH] feat(studio): keyframe diamonds, navigation, context menu [4/6] (#1170) * feat(core): GSAP keyframe parsing, mutations, and API routes * 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 * fix(producer): use video stream duration for PSNR checkpoint range The regression harness used container duration (format.duration) to compute PSNR checkpoints. Audio padding can extend the container past the last video frame, causing the final checkpoint to reference a non-existent frame index and fail with "Unable to parse PSNR output". Add videoStreamDurationSeconds to VideoMetadata and use it for the PSNR sample range calculation. * 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. * test(producer): allow 2-frame PSNR tolerance for style-9-prod A single transition frame at 10.742s renders with marginal PSNR (26.6 dB vs 30 threshold) on CI runners but passes on the devbox Docker image. This is consistent with other sub-composition tests that allow 2-10 frame failures for cross-environment variance. * feat(studio): GSAP runtime bridge + optimistic update pattern * feat(studio): keyframe diamonds, navigation controls, context menu --- .../src/components/editor/KeyframeDiamond.tsx | 49 +++++ .../components/editor/KeyframeNavigation.tsx | 139 ++++++++++++++ .../components/KeyframeDiamondContextMenu.tsx | 151 +++++++++++++++ .../components/TimelineClipDiamonds.tsx | 174 ++++++++++++++++++ 4 files changed, 513 insertions(+) create mode 100644 packages/studio/src/components/editor/KeyframeDiamond.tsx create mode 100644 packages/studio/src/components/editor/KeyframeNavigation.tsx create mode 100644 packages/studio/src/player/components/KeyframeDiamondContextMenu.tsx create mode 100644 packages/studio/src/player/components/TimelineClipDiamonds.tsx diff --git a/packages/studio/src/components/editor/KeyframeDiamond.tsx b/packages/studio/src/components/editor/KeyframeDiamond.tsx new file mode 100644 index 000000000..10c7814c8 --- /dev/null +++ b/packages/studio/src/components/editor/KeyframeDiamond.tsx @@ -0,0 +1,49 @@ +import { memo } from "react"; + +export type DiamondState = "active" | "inactive" | "ghost"; + +interface KeyframeDiamondProps { + state: DiamondState; + onClick: () => void; + title?: string; + size?: number; +} + +// fallow-ignore-next-line complexity +export const KeyframeDiamond = memo(function KeyframeDiamond({ + state, + onClick, + title, + size = 10, +}: KeyframeDiamondProps) { + const isFilled = state === "active"; + const opacity = state === "ghost" ? 0.25 : state === "inactive" ? 0.6 : 1; + const color = state === "active" ? "#3b82f6" : "#a3a3a3"; + + return ( + + ); +}); diff --git a/packages/studio/src/components/editor/KeyframeNavigation.tsx b/packages/studio/src/components/editor/KeyframeNavigation.tsx new file mode 100644 index 000000000..48f2f5177 --- /dev/null +++ b/packages/studio/src/components/editor/KeyframeNavigation.tsx @@ -0,0 +1,139 @@ +import { memo } from "react"; +import { KeyframeDiamond, type DiamondState } from "./KeyframeDiamond"; + +interface KeyframeNavigationProps { + property: string; + /** All keyframes for this element's tween, or null if no keyframes exist */ + keyframes: Array<{ + percentage: number; + properties: Record; + ease?: string; + }> | null; + /** Current playhead percentage within the element's lifetime (0-100) */ + currentPercentage: number; + onSeek: (percentage: number) => void; + onAddKeyframe: (percentage: number) => void; + onRemoveKeyframe: (percentage: number) => void; + onConvertToKeyframes: () => void; +} + +const TOLERANCE = 0.5; + +function ArrowLeft({ disabled }: { disabled: boolean }) { + return ( + + + + ); +} + +function ArrowRight({ disabled }: { disabled: boolean }) { + return ( + + + + ); +} + +// fallow-ignore-next-line complexity +export const KeyframeNavigation = memo(function KeyframeNavigation({ + property, + keyframes, + currentPercentage, + onSeek, + onAddKeyframe, + onRemoveKeyframe, + onConvertToKeyframes, +}: KeyframeNavigationProps) { + // Find keyframes that contain this property + const propertyKeyframes = keyframes?.filter((kf) => property in kf.properties) ?? []; + + const prevKf = + propertyKeyframes.filter((kf) => kf.percentage < currentPercentage - TOLERANCE).at(-1) ?? null; + + const nextKf = + propertyKeyframes.find((kf) => kf.percentage > currentPercentage + TOLERANCE) ?? null; + + const atCurrent = + propertyKeyframes.find((kf) => Math.abs(kf.percentage - currentPercentage) <= TOLERANCE) ?? + null; + + // Diamond state + let diamondState: DiamondState; + if (!keyframes || keyframes.length === 0) { + diamondState = "ghost"; + } else if (atCurrent) { + diamondState = "active"; + } else if (propertyKeyframes.length > 0) { + diamondState = "inactive"; + } else { + diamondState = "ghost"; + } + + const handleDiamondClick = () => { + if (diamondState === "ghost") { + onConvertToKeyframes(); + } else if (diamondState === "active") { + onRemoveKeyframe(currentPercentage); + } else { + onAddKeyframe(currentPercentage); + } + }; + + return ( +
+ + + +
+ ); +}); diff --git a/packages/studio/src/player/components/KeyframeDiamondContextMenu.tsx b/packages/studio/src/player/components/KeyframeDiamondContextMenu.tsx new file mode 100644 index 000000000..9f410a0c4 --- /dev/null +++ b/packages/studio/src/player/components/KeyframeDiamondContextMenu.tsx @@ -0,0 +1,151 @@ +import { memo, useCallback, useEffect, useRef } from "react"; +import { EASE_LABELS } from "../../components/editor/gsapAnimationConstants"; + +export interface KeyframeDiamondContextMenuState { + x: number; + y: number; + elementId: string; + percentage: number; + currentEase?: string; +} + +interface KeyframeDiamondContextMenuProps { + state: KeyframeDiamondContextMenuState; + onClose: () => void; + onDelete: (elementId: string, percentage: number) => void; + onChangeEase: (elementId: string, percentage: number, ease: string) => void; + onCopyProperties: (elementId: string, percentage: number) => void; +} + +const EASE_PRESETS = [ + "none", + "power1.out", + "power2.out", + "power3.out", + "power1.in", + "power2.in", + "power1.inOut", + "power2.inOut", + "back.out", + "elastic.out", + "bounce.out", + "expo.out", +] as const; + +export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMenu({ + state, + onClose, + onDelete, + onChangeEase, + onCopyProperties, +}: KeyframeDiamondContextMenuProps) { + const menuRef = useRef(null); + const easeSubmenuRef = useRef(null); + + const dismiss = useCallback( + (e: MouseEvent | KeyboardEvent) => { + if (e instanceof KeyboardEvent && e.key !== "Escape") return; + if (e instanceof MouseEvent && menuRef.current?.contains(e.target as Node)) return; + onClose(); + }, + [onClose], + ); + + useEffect(() => { + document.addEventListener("mousedown", dismiss); + document.addEventListener("keydown", dismiss); + return () => { + document.removeEventListener("mousedown", dismiss); + document.removeEventListener("keydown", dismiss); + }; + }, [dismiss]); + + const adjustedX = Math.min(state.x, window.innerWidth - 200); + const adjustedY = Math.min(state.y, window.innerHeight - 300); + + const currentEaseLabel = state.currentEase + ? (EASE_LABELS[state.currentEase] ?? state.currentEase) + : "Default"; + + return ( +
+ {/* Ease submenu */} +
+ +
+ {EASE_PRESETS.map((ease) => ( + + ))} +
+
+ + {/* Separator */} +
+ + {/* Delete */} + + + {/* Copy Properties */} + +
+ ); +}); diff --git a/packages/studio/src/player/components/TimelineClipDiamonds.tsx b/packages/studio/src/player/components/TimelineClipDiamonds.tsx new file mode 100644 index 000000000..98bde2bd1 --- /dev/null +++ b/packages/studio/src/player/components/TimelineClipDiamonds.tsx @@ -0,0 +1,174 @@ +import { memo, useRef } from "react"; + +interface KeyframeEntry { + percentage: number; + properties: Record; + ease?: string; +} + +interface KeyframeCacheEntry { + format: string; + keyframes: KeyframeEntry[]; + ease?: string; + easeEach?: string; +} + +interface TimelineClipDiamondsProps { + keyframesData: KeyframeCacheEntry; + clipWidthPx: number; + clipHeightPx: number; + accentColor: string; + isSelected: boolean; + currentPercentage: number; + elementId: string; + selectedKeyframes: Set; + onClickKeyframe?: (percentage: number) => void; + onShiftClickKeyframe?: (elementId: string, percentage: number) => void; + onDragKeyframe?: (percentage: number, newPercentage: number) => void; + onContextMenuKeyframe?: (e: React.MouseEvent, elementId: string, percentage: number) => void; +} + +const DIAMOND_RATIO = 0.8; + +export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({ + keyframesData, + clipWidthPx, + clipHeightPx, + accentColor, + isSelected, + currentPercentage, + elementId, + selectedKeyframes, + onClickKeyframe, + onShiftClickKeyframe, + onDragKeyframe, + onContextMenuKeyframe, +}: TimelineClipDiamondsProps) { + const dragRef = useRef<{ startX: number; startPct: number } | null>(null); + + if (clipWidthPx < 20) return null; + + const diamondSize = Math.round(clipHeightPx * DIAMOND_RATIO); + const half = diamondSize / 2; + const sorted = keyframesData.keyframes.slice().sort((a, b) => a.percentage - b.percentage); + const baseColor = isSelected ? accentColor : "#a3a3a3"; + const baseOpacity = isSelected ? 0.4 : 0.25; + + const handleClick = (e: React.MouseEvent, pct: number) => { + e.stopPropagation(); + if (e.shiftKey) { + onShiftClickKeyframe?.(elementId, pct); + } else { + onClickKeyframe?.(pct); + } + }; + + const handlePointerDown = (e: React.PointerEvent, pct: number) => { + if (e.button !== 0) return; + e.stopPropagation(); + const startX = e.clientX; + + const handleMove = (me: PointerEvent) => { + const dx = me.clientX - startX; + if (Math.abs(dx) > 4) { + dragRef.current = { startX, startPct: pct }; + } + }; + + const handleUp = (ue: PointerEvent) => { + document.removeEventListener("pointermove", handleMove); + document.removeEventListener("pointerup", handleUp); + const start = dragRef.current; + dragRef.current = null; + if (!start) return; + const dx = ue.clientX - start.startX; + const dPct = (dx / clipWidthPx) * 100; + const newPct = Math.max(0, Math.min(100, Math.round(start.startPct + dPct))); + if (Math.abs(newPct - start.startPct) > 0.5) { + onDragKeyframe?.(start.startPct, newPct); + } + }; + + document.addEventListener("pointermove", handleMove); + document.addEventListener("pointerup", handleUp); + }; + + return ( +
+ {sorted.map((kf, i) => { + if (i === 0) return null; + const prev = sorted[i - 1]!; + const x1 = (prev.percentage / 100) * clipWidthPx; + const x2 = (kf.percentage / 100) * clipWidthPx; + return ( +
+ ); + })} + + {sorted.map((kf) => { + 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 color = isKfSelected || atPlayhead ? accentColor : "#a3a3a3"; + return ( + + ); + })} +
+ ); +});