From a5211954ea270cefc2edb260bab12b039e8ccf82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 5 Jun 2026 12:05:55 -0400 Subject: [PATCH] feat(studio): design panel, timeline polish, feature flag [6/6] (#1172) 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. --- packages/studio/src/App.tsx | 38 ++-- .../src/components/StudioPreviewArea.tsx | 54 +++++ .../src/components/editor/AnimationCard.tsx | 72 +++++- .../src/components/editor/PropertyPanel.tsx | 177 ++++++++++++--- .../editor/gsapAnimationConstants.ts | 42 ++++ .../components/editor/gsapAnimationHelpers.ts | 3 +- .../editor/manualEditingAvailability.ts | 6 + .../src/components/editor/manualEditsDom.ts | 8 +- .../src/components/editor/manualOffsetDrag.ts | 22 +- .../studio/src/components/nle/NLELayout.tsx | 15 ++ .../studio/src/contexts/DomEditContext.tsx | 21 ++ .../studio/src/hooks/gsapRuntimeBridge.ts | 206 +++++++++++++++++- .../studio/src/hooks/useDomEditCommits.ts | 6 +- .../studio/src/hooks/useDomEditSession.ts | 72 +++++- .../components/KeyframeDiamondContextMenu.tsx | 13 ++ .../studio/src/player/components/Timeline.tsx | 72 ++++++ .../src/player/components/TimelineCanvas.tsx | 42 +++- .../src/player/components/TimelineClip.tsx | 4 +- .../src/player/components/timelineLayout.ts | 2 +- packages/studio/src/utils/editHistory.ts | 2 +- 20 files changed, 800 insertions(+), 77 deletions(-) diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 56af6bef2..8171302ab 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -266,8 +266,10 @@ export function StudioApp() { const handleDomEditElementDeleteRef = useRef<(s: DomEditSelection) => Promise>( async () => {}, ); - const domEditDeleteBridge = async (s: DomEditSelection) => - handleDomEditElementDeleteRef.current(s); + const domEditDeleteBridge = (s: DomEditSelection) => handleDomEditElementDeleteRef.current(s); + const resetKeyframesRef = useRef<() => boolean>(() => false); + const deleteSelectedKeyframesRef = useRef<() => void>(() => {}); + const invalidateGsapCacheRef = useRef<() => void>(() => {}); const { handleCopy, handlePaste, handleCut } = useClipboard({ projectId, activeCompPath, @@ -299,8 +301,10 @@ export function StudioApp() { handleCopy, handlePaste, handleCut, + onResetKeyframes: () => resetKeyframesRef.current(), + onDeleteSelectedKeyframes: () => deleteSelectedKeyframesRef.current(), + onAfterUndoRedo: () => invalidateGsapCacheRef.current(), }); - const selectSidebarTabStable = useCallback( (tab: SidebarTab) => leftSidebarRef.current?.selectTab(tab), [], @@ -345,11 +349,20 @@ export function StudioApp() { selectSidebarTab: selectSidebarTabStable, getSidebarTab: getSidebarTabStable, }); - domEditSelectionBridgeRef.current = domEditSession.domEditSelection; clearDomSelectionRef.current = domEditSession.clearDomSelection; handleDomEditElementDeleteRef.current = domEditSession.handleDomEditElementDelete; - + resetKeyframesRef.current = domEditSession.handleResetSelectedElementKeyframes; + invalidateGsapCacheRef.current = domEditSession.invalidateGsapCache; + deleteSelectedKeyframesRef.current = () => { + const sk = usePlayerStore.getState().selectedKeyframes; + const a = domEditSession.selectedGsapAnimations.find((x) => x.keyframes); + if (!a || sk.size === 0) return; + sk.forEach((k) => { + const p = Number(k.split(":")[1]); + if (Number.isFinite(p)) domEditSession.handleGsapRemoveKeyframe(a.id, p); + }); + }; useCaptionDetection({ projectId, activeCompPath, @@ -470,12 +483,14 @@ export function StudioApp() { timelineVisible, toggleTimelineVisibility, }); - - if (resolving || waitingForServer || !projectId) { + if (resolving || waitingForServer || !projectId) return ; - } - - const timelineToolbar = ; + const timelineToolbar = ( + + ); return ( @@ -540,7 +555,6 @@ export function StudioApp() { {lintModal !== null && ( )} - {consoleErrors !== null && consoleErrors.length > 0 && ( setConsoleErrors(null)} /> )} - {domEditSession.agentModalOpen && domEditSession.domEditSelection && ( } - {appToast && (
{ + 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); + }} + onChangeKeyframeEase={(_elId, _pct, ease) => { + const anim = selectedGsapAnimations.find((a) => a.keyframes); + if (anim) handleGsapUpdateMeta(anim.id, { ease }); + }} + // fallow-ignore-next-line complexity + onMoveKeyframe={(_el, oldPct, newPct) => { + const anim = selectedGsapAnimations.find((a) => a.keyframes); + if (!anim?.keyframes) return; + const kf = anim.keyframes.keyframes.find((k) => k.percentage === oldPct); + if (!kf) return; + handleGsapRemoveKeyframe(anim.id, oldPct); + for (const [prop, val] of Object.entries(kf.properties)) { + handleGsapAddKeyframe(anim.id, newPct, prop, val); + } + }} + onToggleKeyframeAtPlayhead={(el) => { + const currentTime = usePlayerStore.getState().currentTime; + const pct = + el.duration > 0 + ? Math.max( + 0, + Math.min(100, Math.round(((currentTime - el.start) / el.duration) * 100)), + ) + : 0; + const anim = selectedGsapAnimations.find((a) => a.keyframes); + if (anim?.keyframes) { + const existing = anim.keyframes.keyframes.find( + (k) => Math.abs(k.percentage - pct) <= 1, + ); + if (existing) { + handleGsapRemoveKeyframe(anim.id, existing.percentage); + } else { + handleGsapAddKeyframe(anim.id, pct, "x", 0); + } + } else { + const flatAnim = selectedGsapAnimations.find((a) => !a.keyframes); + if (flatAnim) handleGsapConvertToKeyframes(flatAnim.id); + } + }} onCompIdToSrcChange={setCompIdToSrc} onCompositionLoadingChange={setCompositionLoading} onCompositionChange={(compPath) => { diff --git a/packages/studio/src/components/editor/AnimationCard.tsx b/packages/studio/src/components/editor/AnimationCard.tsx index 3849ce048..839d3f7b3 100644 --- a/packages/studio/src/components/editor/AnimationCard.tsx +++ b/packages/studio/src/components/editor/AnimationCard.tsx @@ -9,13 +9,29 @@ import { METHOD_LABELS, METHOD_TOOLTIPS, PERCENT_PROPS, + PROP_CONSTRAINTS, PROP_LABELS, PROP_TOOLTIPS, PROP_UNITS, + clampPropertyValue, } from "./gsapAnimationConstants"; import { buildTweenSummary } from "./gsapAnimationHelpers"; import { EaseCurveSection } from "./EaseCurveSection"; const BOOLEAN_PROPS = new Set(["visibility"]); +const STRING_PROPS = new Set(["filter", "clipPath"]); + +const FILTER_PRESETS = [ + { label: "Blur", value: "blur(4px)" }, + { label: "Bright", value: "brightness(1.5)" }, + { label: "Gray", value: "grayscale(1)" }, + { label: "None", value: "none" }, +]; + +const CLIP_PATH_PRESETS = [ + { label: "Circle", value: "circle(50% at 50% 50%)" }, + { label: "Inset", value: "inset(10%)" }, + { label: "None", value: "none" }, +]; function isPercentProp(prop: string): boolean { return PERCENT_PROPS.has(prop); @@ -27,7 +43,11 @@ function displayValue(prop: string, val: number | string): string { } function adjustedValue(prop: string, raw: string): string { - if (isPercentProp(prop)) return String(Math.max(0, Math.min(1, Number(raw) / 100))); + if (isPercentProp(prop)) return String(clampPropertyValue(prop, Number(raw) / 100)); + const num = Number(raw); + if (!Number.isNaN(num) && PROP_CONSTRAINTS[prop]) { + return String(clampPropertyValue(prop, num)); + } return raw; } @@ -90,6 +110,48 @@ function PropertyRow({ ); } + if (STRING_PROPS.has(prop)) { + const presets = + prop === "filter" ? FILTER_PRESETS : prop === "clipPath" ? CLIP_PATH_PRESETS : []; + return ( +
+
+
+ + {PROP_LABELS[prop] ?? prop} + + onCommit(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.currentTarget.blur(); + } + }} + /> +
+ +
+ {presets.length > 0 && ( +
+ {presets.map((p) => ( + + ))} +
+ )} +
+ ); + } + return (
@@ -292,8 +354,10 @@ export const AnimationCard = memo(function AnimationCard({ {methodLabel} - {typeof animation.position === "number" ? `${animation.position}s` : animation.position} –{" "} - {typeof endTime === "number" ? `${endTime.toFixed(1)}s` : endTime} + {typeof animation.position === "number" + ? `${parseFloat(animation.position.toFixed(3))}s` + : animation.position}{" "} + – {typeof endTime === "number" ? `${parseFloat(endTime.toFixed(3))}s` : endTime} {easeLabel} @@ -344,7 +408,7 @@ export const AnimationCard = memo(function AnimationCard({ value={ typeof animation.position === "string" ? animation.position - : String(Math.max(0, animation.position)) + : String(parseFloat(Math.max(0, animation.position).toFixed(3))) } suffix={typeof animation.position === "number" ? "s" : undefined} tooltip="When this effect begins on the timeline" diff --git a/packages/studio/src/components/editor/PropertyPanel.tsx b/packages/studio/src/components/editor/PropertyPanel.tsx index e93d2c1cd..19dc6e8d9 100644 --- a/packages/studio/src/components/editor/PropertyPanel.tsx +++ b/packages/studio/src/components/editor/PropertyPanel.tsx @@ -14,7 +14,9 @@ import { MetricField, Section } from "./propertyPanelPrimitives"; import { isMediaElement, MediaSection } from "./propertyPanelMediaSection"; import { TextSection, StyleSections } from "./propertyPanelSections"; import { GsapAnimationSection } from "./GsapAnimationSection"; -import { STUDIO_GSAP_PANEL_ENABLED } from "./manualEditingAvailability"; +import { KeyframeNavigation } from "./KeyframeNavigation"; +import { STUDIO_GSAP_PANEL_ENABLED, STUDIO_KEYFRAMES_ENABLED } from "./manualEditingAvailability"; +import { usePlayerStore } from "../../player"; // Re-export helpers that external consumers import from this module export { @@ -65,6 +67,15 @@ interface PropertyPanelProps { onAddGsapFromProperty?: (animId: string, prop: string) => void; onRemoveGsapFromProperty?: (animId: string, prop: string) => void; onAddGsapAnimation?: (method: "to" | "from" | "set" | "fromTo") => void; + onAddKeyframe?: ( + animationId: string, + percentage: number, + property: string, + value: number | string, + ) => void; + onRemoveKeyframe?: (animationId: string, percentage: number) => void; + onConvertToKeyframes?: (animationId: string) => void; + onSeekToTime?: (time: number) => void; } /* ------------------------------------------------------------------ */ @@ -170,6 +181,10 @@ export const PropertyPanel = memo(function PropertyPanel({ onAddGsapFromProperty, onRemoveGsapFromProperty, onAddGsapAnimation, + onAddKeyframe, + onRemoveKeyframe, + onConvertToKeyframes, + onSeekToTime, }: PropertyPanelProps) { const styles = element?.computedStyles ?? EMPTY_STYLES; @@ -223,6 +238,11 @@ export const PropertyPanel = memo(function PropertyPanel({ const commitManualOffset = (axis: "x" | "y", nextValue: string) => { const parsed = parsePxMetricValue(nextValue); if (parsed == null) return; + if (gsapKeyframes && gsapAnimId && onAddKeyframe) { + const pct = Math.max(0, Math.min(100, Math.round(currentPct * 10) / 10)); + onAddKeyframe(gsapAnimId, pct, axis, parsed); + return; + } const current = readStudioPathOffset(element.element); onSetManualOffset(element, { x: axis === "x" ? parsed : current.x, @@ -256,6 +276,16 @@ export const PropertyPanel = memo(function PropertyPanel({ onSetManualRotation(element, { angle: parsed }); }; + // Keyframe navigation state + const elStart = Number.parseFloat(element?.dataAttributes?.start ?? "0") || 0; + const elDuration = Number.parseFloat(element?.dataAttributes?.duration ?? "1") || 0; + const currentTime = usePlayerStore((s) => s.currentTime); + const currentPct = elDuration > 0 ? ((currentTime - elStart) / elDuration) * 100 : 0; + + const gsapKeyframes = gsapAnimations?.find((a) => a.keyframes)?.keyframes?.keyframes ?? null; + const gsapAnimId = + gsapAnimations?.find((a) => a.keyframes)?.id ?? gsapAnimations?.[0]?.id ?? null; + return (
@@ -317,39 +347,118 @@ export const PropertyPanel = memo(function PropertyPanel({
}>
- commitManualOffset("x", next)} - /> - commitManualOffset("y", next)} - /> - commitManualSize("width", next)} - /> - commitManualSize("height", next)} - /> - commitManualRotation(next.replace("°", ""))} - /> +
+
+ commitManualOffset("x", next)} + /> +
+ {STUDIO_KEYFRAMES_ENABLED && gsapAnimId && ( + onSeekToTime?.(elStart + (pct / 100) * elDuration)} + onAddKeyframe={(pct) => onAddKeyframe?.(gsapAnimId, pct, "x", manualOffset.x)} + onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)} + onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)} + /> + )} +
+
+
+ commitManualOffset("y", next)} + /> +
+ {STUDIO_KEYFRAMES_ENABLED && gsapAnimId && ( + onSeekToTime?.(elStart + (pct / 100) * elDuration)} + onAddKeyframe={(pct) => onAddKeyframe?.(gsapAnimId, pct, "y", manualOffset.y)} + onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)} + onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)} + /> + )} +
+
+
+ commitManualSize("width", next)} + /> +
+ {STUDIO_KEYFRAMES_ENABLED && gsapAnimId && ( + onSeekToTime?.(elStart + (pct / 100) * elDuration)} + onAddKeyframe={(pct) => onAddKeyframe?.(gsapAnimId, pct, "width", resolvedWidth)} + onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)} + onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)} + /> + )} +
+
+
+ commitManualSize("height", next)} + /> +
+ {STUDIO_KEYFRAMES_ENABLED && gsapAnimId && ( + onSeekToTime?.(elStart + (pct / 100) * elDuration)} + onAddKeyframe={(pct) => + onAddKeyframe?.(gsapAnimId, pct, "height", resolvedHeight) + } + onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)} + onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)} + /> + )} +
+
+
+ commitManualRotation(next.replace("°", ""))} + /> +
+ {STUDIO_KEYFRAMES_ENABLED && gsapAnimId && ( + onSeekToTime?.(elStart + (pct / 100) * elDuration)} + onAddKeyframe={(pct) => + onAddKeyframe?.(gsapAnimId, pct, "rotation", manualRotation.angle) + } + onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)} + onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)} + /> + )} +
= { autoAlpha: "Visibility", visibility: "Visible", scaleX_alias: "Stretch X", + filter: "Filter", + clipPath: "Clip Path", + color: "Color", + backgroundColor: "Background", + borderColor: "Border Color", + borderRadius: "Radius", + fontSize: "Font Size", + letterSpacing: "Tracking", + skewX: "Skew X", + skewY: "Skew Y", }; export const PROP_UNITS: Record = { @@ -83,6 +93,11 @@ export const EASE_LABELS: Record = { "expo.out": "Very snappy stop", "expo.in": "Very slow start", "expo.inOut": "Dramatic ease", + "spring-gentle": "Gentle spring", + "spring-bouncy": "Bouncy spring", + "spring-stiff": "Stiff spring", + "spring-wobbly": "Wobbly spring", + "spring-heavy": "Heavy spring", }; export const EASE_CURVES: Record = { @@ -123,6 +138,33 @@ export function parseCustomEaseFromString(ease: string): { export const PERCENT_PROPS = new Set(["opacity", "autoAlpha"]); +export const PROP_CONSTRAINTS: Record = { + opacity: { min: 0, max: 1, step: 0.01 }, + autoAlpha: { min: 0, max: 1, step: 0.01 }, + scale: { min: -10, max: 10, step: 0.01 }, + scaleX: { min: -10, max: 10, step: 0.01 }, + scaleY: { min: -10, max: 10, step: 0.01 }, + rotation: { step: 1 }, + skewX: { min: -90, max: 90, step: 1 }, + skewY: { min: -90, max: 90, step: 1 }, + width: { min: 0, step: 1 }, + height: { min: 0, step: 1 }, + borderRadius: { min: 0, step: 1 }, + x: { step: 1 }, + y: { step: 1 }, + fontSize: { min: 1, step: 1 }, + letterSpacing: { step: 0.1 }, +}; + +export function clampPropertyValue(prop: string, value: number): number { + const constraint = PROP_CONSTRAINTS[prop]; + if (!constraint) return value; + let clamped = value; + if (constraint.min !== undefined) clamped = Math.max(constraint.min, clamped); + if (constraint.max !== undefined) clamped = Math.min(constraint.max, clamped); + return clamped; +} + export const ADD_METHODS = ["to", "from", "fromTo", "set"] as const; export const ADD_METHOD_LABELS: Record = { diff --git a/packages/studio/src/components/editor/gsapAnimationHelpers.ts b/packages/studio/src/components/editor/gsapAnimationHelpers.ts index 87cfca545..911cbcd3c 100644 --- a/packages/studio/src/components/editor/gsapAnimationHelpers.ts +++ b/packages/studio/src/components/editor/gsapAnimationHelpers.ts @@ -14,7 +14,8 @@ export function buildTweenSummary(animation: GsapAnimation): string { const props = Object.entries(animation.properties); const target = animation.targetSelector; const dur = animation.duration ?? 0; - const pos = animation.position; + const rawPos = animation.position; + const pos = typeof rawPos === "number" ? parseFloat(rawPos.toFixed(3)) : rawPos; const propDescs = props.map(([p, v]) => { const label = (PROP_LABELS[p] ?? p).toLowerCase(); return `${label} to ${formatPropValue(p, v)}`; diff --git a/packages/studio/src/components/editor/manualEditingAvailability.ts b/packages/studio/src/components/editor/manualEditingAvailability.ts index 6845956a6..553bb5e8d 100644 --- a/packages/studio/src/components/editor/manualEditingAvailability.ts +++ b/packages/studio/src/components/editor/manualEditingAvailability.ts @@ -68,6 +68,12 @@ export const STUDIO_BLOCKS_PANEL_ENABLED = resolveStudioBooleanEnvFlag( export const STUDIO_GSAP_PANEL_ENABLED = resolveStudioBooleanEnvFlag( env, ["VITE_STUDIO_ENABLE_GSAP_PANEL", "VITE_STUDIO_GSAP_PANEL_ENABLED"], + true, +); + +export const STUDIO_KEYFRAMES_ENABLED = resolveStudioBooleanEnvFlag( + env, + ["VITE_STUDIO_ENABLE_KEYFRAMES", "VITE_STUDIO_KEYFRAMES_ENABLED"], false, ); diff --git a/packages/studio/src/components/editor/manualEditsDom.ts b/packages/studio/src/components/editor/manualEditsDom.ts index b08a8c567..c4e50a0d1 100644 --- a/packages/studio/src/components/editor/manualEditsDom.ts +++ b/packages/studio/src/components/editor/manualEditsDom.ts @@ -223,6 +223,7 @@ function isIdentityAfterTranslateStrip(m: DOMMatrix): boolean { } function stripGsapTranslateFromTransform(element: HTMLElement): void { + if (element.hasAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR)) return; const transform = element.style.getPropertyValue("transform"); if (!transform || transform === "none") return; const DOMMatrixCtor = (element.ownerDocument.defaultView as (Window & typeof globalThis) | null) @@ -233,8 +234,11 @@ function stripGsapTranslateFromTransform(element: HTMLElement): void { if (m.m41 === 0 && m.m42 === 0) return; const offsetX = readPxCustomProperty(element, STUDIO_OFFSET_X_PROP); const offsetY = readPxCustomProperty(element, STUDIO_OFFSET_Y_PROP); - m.m41 -= offsetX; - m.m42 -= offsetY; + const angle = Math.atan2(m.b, m.a); + const cos = Math.cos(angle); + const sin = Math.sin(angle); + m.m41 -= offsetX * cos - offsetY * sin; + m.m42 -= offsetX * sin + offsetY * cos; if (Math.abs(m.m41) < 0.01 && Math.abs(m.m42) < 0.01 && isIdentityAfterTranslateStrip(m)) { element.style.removeProperty("transform"); } else { diff --git a/packages/studio/src/components/editor/manualOffsetDrag.ts b/packages/studio/src/components/editor/manualOffsetDrag.ts index 67aae3397..9df465a00 100644 --- a/packages/studio/src/components/editor/manualOffsetDrag.ts +++ b/packages/studio/src/components/editor/manualOffsetDrag.ts @@ -236,9 +236,25 @@ export function createManualOffsetDragMember(input: { const gestureToken = beginStudioManualEditGesture(input.element); const measured = measureManualOffsetDragScreenToOffsetMatrix(input.element, initialOffset); if (!measured.ok) { - restoreStudioPathOffset(input.element, initialPathOffset); - endStudioManualEditGesture(input.element, gestureToken); - return { ok: false, reason: measured.reason, selection: input.selection }; + // Fallback: when GSAP transforms interfere with probe measurement, use + // the preview scale as an approximation. The commit path reads the actual + // GSAP position from the iframe runtime, so visual imprecision during + // drag is acceptable — the final committed position is always exact. + const scaleX = input.rect.editScaleX || 1; + const scaleY = input.rect.editScaleY || 1; + return { + ok: true, + member: { + key: input.key, + selection: input.selection, + element: input.element, + initialOffset, + initialPathOffset, + gestureToken, + screenToOffset: { a: 1 / scaleX, b: 0, c: 0, d: 1 / scaleY }, + originRect: input.rect, + }, + }; } return { diff --git a/packages/studio/src/components/nle/NLELayout.tsx b/packages/studio/src/components/nle/NLELayout.tsx index 07562e113..cad19d33f 100644 --- a/packages/studio/src/components/nle/NLELayout.tsx +++ b/packages/studio/src/components/nle/NLELayout.tsx @@ -70,6 +70,11 @@ interface NLELayoutProps { ) => Promise | void; 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; /** Exposes the compIdToSrc map for parent components (e.g., useRenderClipContent) */ onCompIdToSrcChange?: (map: Map) => void; /** Whether the timeline panel is visible (default: true) */ @@ -118,6 +123,11 @@ export const NLELayout = memo(function NLELayout({ onResizeElement, onBlockedEditAttempt, onSelectTimelineElement, + onDeleteKeyframe, + onDeleteAllKeyframes, + onChangeKeyframeEase, + onMoveKeyframe, + onToggleKeyframeAtPlayhead, onCompIdToSrcChange, timelineVisible, onToggleTimeline, @@ -448,6 +458,11 @@ export const NLELayout = memo(function NLELayout({ onResizeElement={onResizeElement} onBlockedEditAttempt={onBlockedEditAttempt} onSelectElement={onSelectTimelineElement} + onDeleteKeyframe={onDeleteKeyframe} + onDeleteAllKeyframes={onDeleteAllKeyframes} + onChangeKeyframeEase={onChangeKeyframeEase} + onMoveKeyframe={onMoveKeyframe} + onToggleKeyframeAtPlayhead={onToggleKeyframeAtPlayhead} />
{timelineFooter &&
{timelineFooter}
} diff --git a/packages/studio/src/contexts/DomEditContext.tsx b/packages/studio/src/contexts/DomEditContext.tsx index b91d54a49..39b7183da 100644 --- a/packages/studio/src/contexts/DomEditContext.tsx +++ b/packages/studio/src/contexts/DomEditContext.tsx @@ -65,6 +65,13 @@ export function DomEditProvider({ handleGsapUpdateFromProperty, handleGsapAddFromProperty, handleGsapRemoveFromProperty, + handleGsapAddKeyframe, + handleGsapRemoveKeyframe, + handleGsapConvertToKeyframes, + handleGsapRemoveAllKeyframes, + handleResetSelectedElementKeyframes, + invalidateGsapCache, + previewIframeRef, }, children, }: { @@ -125,6 +132,13 @@ export function DomEditProvider({ handleGsapUpdateFromProperty, handleGsapAddFromProperty, handleGsapRemoveFromProperty, + handleGsapAddKeyframe, + handleGsapRemoveKeyframe, + handleGsapConvertToKeyframes, + handleGsapRemoveAllKeyframes, + handleResetSelectedElementKeyframes, + invalidateGsapCache, + previewIframeRef, }), [ domEditSelection, @@ -179,6 +193,13 @@ export function DomEditProvider({ handleGsapUpdateFromProperty, handleGsapAddFromProperty, handleGsapRemoveFromProperty, + handleGsapAddKeyframe, + handleGsapRemoveKeyframe, + handleGsapConvertToKeyframes, + handleGsapRemoveAllKeyframes, + handleResetSelectedElementKeyframes, + invalidateGsapCache, + previewIframeRef, ], ); return {children}; diff --git a/packages/studio/src/hooks/gsapRuntimeBridge.ts b/packages/studio/src/hooks/gsapRuntimeBridge.ts index a8841318d..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,14 +173,33 @@ 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 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") { @@ -186,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, + ); } } @@ -194,8 +222,7 @@ async function commitGsapPositionFromDrag( async function commitKeyframedPosition( selection: DomEditSelection, anim: GsapAnimation, - newX: number, - newY: number, + properties: Record, callbacks: GsapDragCommitCallbacks, beforeReload: () => void, ): Promise { @@ -207,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 }, ); @@ -222,8 +249,7 @@ async function commitKeyframedPosition( async function commitFlatViaKeyframes( selection: DomEditSelection, anim: GsapAnimation, - newX: number, - newY: number, + properties: Record, callbacks: GsapDragCommitCallbacks, beforeReload: () => void, ): Promise { @@ -241,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 }, ); @@ -305,3 +331,161 @@ async function commitFromToPosition( { label: "Move (GSAP to y)", softReload: true, beforeReload }, ); } + +// ── 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( + selection: DomEditSelection, + size: { width: number; height: number }, + animations: GsapAnimation[], + iframe: HTMLIFrameElement | null, + commitMutation: GsapDragCommitCallbacks["commitMutation"], + fetchFallbackAnimations?: () => Promise, +): Promise { + let anim = animations.find( + (a) => "width" in a.properties || "height" in a.properties || a.keyframes, + ); + if (!anim && fetchFallbackAnimations) { + const fresh = await fetchFallbackAnimations(); + anim = fresh.find((a) => "width" in a.properties || "height" in a.properties || a.keyframes); + } + if (!anim) return false; + + const pct = computeCurrentPercentage(selection); + + if (!anim.keyframes) { + await commitMutation( + selection, + { type: "convert-to-keyframes", animationId: anim.id }, + { label: "Convert to keyframes for resize", skipReload: true }, + ); + } + + 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, + }, + { label: `Resize (keyframe ${pct}%)`, softReload: true }, + ); + return true; +} + +// ── Rotation intercept ──────────────────────────────────────────────────── + +export async function tryGsapRotationIntercept( + selection: DomEditSelection, + angle: number, + animations: GsapAnimation[], + iframe: HTMLIFrameElement | null, + commitMutation: GsapDragCommitCallbacks["commitMutation"], + fetchFallbackAnimations?: () => Promise, +): Promise { + let anim = animations.find((a) => "rotation" in a.properties || a.keyframes); + if (!anim && fetchFallbackAnimations) { + const fresh = await fetchFallbackAnimations(); + anim = fresh.find((a) => "rotation" in a.properties || a.keyframes); + } + if (!anim) return false; + + const selector = selectorForSelection(selection); + if (!selector) return false; + + let gsapRotation = 0; + if (iframe?.contentWindow) { + try { + const gsap = ( + iframe.contentWindow as unknown as { + gsap?: { getProperty: (el: Element, prop: string) => number }; + } + ).gsap; + const doc = iframe.contentDocument; + const el = doc?.querySelector(selector); + if (gsap?.getProperty && el) { + gsapRotation = Number(gsap.getProperty(el, "rotation")) || 0; + } + } catch { + /* cross-origin guard */ + } + } + + const pct = computeCurrentPercentage(selection); + const newRotation = Math.round(gsapRotation + angle); + + if (!anim.keyframes) { + await commitMutation( + selection, + { type: "convert-to-keyframes", animationId: anim.id }, + { label: "Convert to keyframes for rotation", skipReload: true }, + ); + } + + const runtimeProps = readAllAnimatedProperties(iframe, selector, anim); + const properties = { ...runtimeProps, rotation: newRotation }; + + await commitMutation( + selection, + { + type: "add-keyframe", + animationId: anim.id, + percentage: pct, + properties, + }, + { label: `Rotate (keyframe ${pct}%)`, softReload: true }, + ); + return true; +} diff --git a/packages/studio/src/hooks/useDomEditCommits.ts b/packages/studio/src/hooks/useDomEditCommits.ts index 658740151..0a995341f 100644 --- a/packages/studio/src/hooks/useDomEditCommits.ts +++ b/packages/studio/src/hooks/useDomEditCommits.ts @@ -352,23 +352,25 @@ export function useDomEditCommits({ const handleDomBoxSizeCommit = useCallback( (selection: DomEditSelection, next: { width: number; height: number }) => { applyStudioBoxSize(selection.element, next); + if (isElementGsapTargeted(previewIframeRef.current, selection.element)) return; commitPositionPatchToHtml(selection, buildBoxSizePatches(selection.element), { label: "Resize layer box", coalesceKey: `box-size:${getDomEditTargetKey(selection)}`, }); }, - [commitPositionPatchToHtml], + [commitPositionPatchToHtml, previewIframeRef], ); const handleDomRotationCommit = useCallback( (selection: DomEditSelection, next: { angle: number }) => { applyStudioRotation(selection.element, next); + if (isElementGsapTargeted(previewIframeRef.current, selection.element)) return; commitPositionPatchToHtml(selection, buildRotationPatches(selection.element), { label: "Rotate layer", coalesceKey: `rotation:${getDomEditTargetKey(selection)}`, }); }, - [commitPositionPatchToHtml], + [commitPositionPatchToHtml, previewIframeRef], ); const handleDomManualEditsReset = useCallback( diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index 209be746a..5edaef835 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -23,7 +23,11 @@ import { fetchParsedAnimations, getAnimationsForElement, } from "./useGsapTweenCache"; -import { tryGsapDragIntercept } from "./gsapRuntimeBridge"; +import { + tryGsapDragIntercept, + tryGsapResizeIntercept, + tryGsapRotationIntercept, +} from "./gsapRuntimeBridge"; // ── Types ── @@ -326,6 +330,68 @@ export function useDomEditSession({ ], ); + const makeFetchFallback = useCallback( + (selection: DomEditSelection) => async () => { + const pid = projectId; + if (!pid) return []; + const parsed = await fetchParsedAnimations(pid, gsapSourceFile); + if (!parsed) return []; + return getAnimationsForElement(parsed.animations, { + id: selection.id ?? null, + selector: selection.selector ?? null, + }); + }, + [projectId, gsapSourceFile], + ); + + const handleGsapAwareBoxSizeCommit = useCallback( + async (selection: DomEditSelection, next: { width: number; height: number }) => { + if (gsapCommitMutation) { + const handled = await tryGsapResizeIntercept( + selection, + next, + selectedGsapAnimations, + previewIframeRef.current, + gsapCommitMutation, + makeFetchFallback(selection), + ); + if (handled) return; + } + handleDomBoxSizeCommit(selection, next); + }, + [ + handleDomBoxSizeCommit, + selectedGsapAnimations, + gsapCommitMutation, + previewIframeRef, + makeFetchFallback, + ], + ); + + const handleGsapAwareRotationCommit = useCallback( + async (selection: DomEditSelection, next: { angle: number }) => { + if (gsapCommitMutation) { + const handled = await tryGsapRotationIntercept( + selection, + next.angle, + selectedGsapAnimations, + previewIframeRef.current, + gsapCommitMutation, + makeFetchFallback(selection), + ); + if (handled) return; + } + handleDomRotationCommit(selection, next); + }, + [ + handleDomRotationCommit, + selectedGsapAnimations, + gsapCommitMutation, + previewIframeRef, + makeFetchFallback, + ], + ); + const handleGsapUpdateProperty = useCallback( (animId: string, prop: string, value: number | string) => { if (!domEditSelection) return; @@ -552,8 +618,8 @@ export function useDomEditSession({ handleDomHtmlAttributeCommit, handleDomPathOffsetCommit: handleGsapAwarePathOffsetCommit, handleDomGroupPathOffsetCommit, - handleDomBoxSizeCommit, - handleDomRotationCommit, + handleDomBoxSizeCommit: handleGsapAwareBoxSizeCommit, + handleDomRotationCommit: handleGsapAwareRotationCommit, handleDomManualEditsReset, handleDomMotionCommit, handleDomMotionClear, diff --git a/packages/studio/src/player/components/KeyframeDiamondContextMenu.tsx b/packages/studio/src/player/components/KeyframeDiamondContextMenu.tsx index 9f410a0c4..593f27f61 100644 --- a/packages/studio/src/player/components/KeyframeDiamondContextMenu.tsx +++ b/packages/studio/src/player/components/KeyframeDiamondContextMenu.tsx @@ -13,6 +13,7 @@ interface KeyframeDiamondContextMenuProps { state: KeyframeDiamondContextMenuState; onClose: () => void; onDelete: (elementId: string, percentage: number) => void; + onDeleteAll: (elementId: string) => void; onChangeEase: (elementId: string, percentage: number, ease: string) => void; onCopyProperties: (elementId: string, percentage: number) => void; } @@ -36,6 +37,7 @@ export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMe state, onClose, onDelete, + onDeleteAll, onChangeEase, onCopyProperties, }: KeyframeDiamondContextMenuProps) { @@ -135,6 +137,17 @@ export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMe Delete Keyframe + + {/* Copy Properties */}
@@ -511,6 +566,23 @@ export const Timeline = memo(function Timeline({ }} /> )} + + {kfContextMenu && ( + 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); + const kf = kfData?.keyframes.find((k) => k.percentage === pct); + if (kf) { + void navigator.clipboard.writeText(JSON.stringify(kf.properties, null, 2)); + } + }} + /> + )}
); }); diff --git a/packages/studio/src/player/components/TimelineCanvas.tsx b/packages/studio/src/player/components/TimelineCanvas.tsx index e9af933ed..1d9233930 100644 --- a/packages/studio/src/player/components/TimelineCanvas.tsx +++ b/packages/studio/src/player/components/TimelineCanvas.tsx @@ -1,5 +1,6 @@ import { memo, type ReactNode } from "react"; import { TimelineClip } from "./TimelineClip"; +import { TimelineClipDiamonds } from "./TimelineClipDiamonds"; import { TimelineRuler } from "./TimelineRuler"; import { getTimelineEditCapabilities, @@ -8,9 +9,10 @@ import { } from "./timelineEditing"; import { getRenderedTimelineElement, type TimelineTheme } from "./timelineTheme"; import { GUTTER, TRACK_H, RULER_H, CLIP_Y, CLIP_HANDLE_W } from "./timelineLayout"; -import type { TimelineElement } from "../store/playerStore"; +import type { TimelineElement, KeyframeCacheEntry } from "../store/playerStore"; import type { DraggedClipState, ResizingClipState, BlockedClipState } from "./useTimelineClipDrag"; import type { TrackVisualStyle } from "./timelineIcons"; +import { STUDIO_KEYFRAMES_ENABLED } from "../../components/editor/manualEditingAvailability"; interface TimelineCanvasProps { major: number[]; @@ -58,6 +60,14 @@ interface TimelineCanvasProps { } | null>; getPreviewElement: (element: TimelineElement) => TimelineElement; getTrackStyle: (tag: string) => TrackVisualStyle; + keyframeCache?: Map; + selectedKeyframes: Set; + currentTime: number; + onClickKeyframe?: (element: TimelineElement, percentage: number) => void; + onShiftClickKeyframe?: (elementId: string, percentage: number) => void; + onDragKeyframe?: (element: TimelineElement, oldPct: number, newPct: number) => void; + onContextMenuKeyframe?: (e: React.MouseEvent, elementId: string, percentage: number) => void; + onToggleKeyframeAtPlayhead?: (element: TimelineElement) => void; } export const TimelineCanvas = memo(function TimelineCanvas({ @@ -99,6 +109,14 @@ export const TimelineCanvas = memo(function TimelineCanvas({ shiftClickClipRef, getPreviewElement, getTrackStyle, + keyframeCache, + selectedKeyframes, + currentTime, + onClickKeyframe, + onShiftClickKeyframe, + onDragKeyframe, + onContextMenuKeyframe, + onToggleKeyframeAtPlayhead: _onToggleKeyframeAtPlayhead, }: TimelineCanvasProps) { const draggedElement = draggedClip?.element ?? null; const activeDraggedElement = @@ -328,6 +346,28 @@ export const TimelineCanvas = memo(function TimelineCanvas({ }} > {renderClipChildren(previewElement, clipStyle)} + {STUDIO_KEYFRAMES_ENABLED && keyframeCache?.get(elementKey) && ( + 0 + ? ((currentTime - previewElement.start) / previewElement.duration) * 100 + : 0 + } + elementId={elementKey} + selectedKeyframes={selectedKeyframes} + onClickKeyframe={(pct) => onClickKeyframe?.(previewElement, pct)} + onShiftClickKeyframe={onShiftClickKeyframe} + onDragKeyframe={(oldPct, newPct) => + onDragKeyframe?.(previewElement, oldPct, newPct) + } + onContextMenuKeyframe={onContextMenuKeyframe} + /> + )} ); })} diff --git a/packages/studio/src/player/components/TimelineClip.tsx b/packages/studio/src/player/components/TimelineClip.tsx index a003a5cb0..c964545a8 100644 --- a/packages/studio/src/player/components/TimelineClip.tsx +++ b/packages/studio/src/player/components/TimelineClip.tsx @@ -69,7 +69,9 @@ export const TimelineClip = memo(function TimelineClip({