From 87bfefbc88d5c5747aad348729e9298e2d1aad71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 3 Jun 2026 01:24:23 -0400 Subject: [PATCH 1/9] feat(core): spring physics solver + runtime fixes + spring ease editor --- packages/core/src/runtime/init.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 2a9200e24..bf4396dec 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -950,6 +950,12 @@ export function initSandboxRuntimeModular(): void { state.capturedTimeline.pause(); const seekTime = Math.max(0, state.currentTime || 0); if (typeof state.capturedTimeline.totalTime === "function") { + // GSAP 3.x skips rendering when totalTime equals the current _tTime. + // A freshly created paused timeline has _tTime=0, so seeking to 0 is a + // no-op — percentage-keyframe values at 0% are never applied. Nudge to + // a micro-offset first to force GSAP to dirty its internal state, then + // seek to the real time so the render produces exact values. + state.capturedTimeline.totalTime(seekTime + 0.001, true); state.capturedTimeline.totalTime(seekTime, false); } From a743c206db1009dd9486b33f1e077d02ce9ceade Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 4 Jun 2026 16:14:24 +0000 Subject: [PATCH 2/9] feat(core): spring physics solver + runtime fixes + spring ease editor Revert totalTime nudge that caused black first frames in from() tweens. Keep stale CSS offset cleanup. Regenerate baselines for offset cleanup. --- packages/core/src/runtime/init.ts | 6 ------ packages/producer/tests/style-9-prod/output/output.mp4 | 4 ++-- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index bf4396dec..2a9200e24 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -950,12 +950,6 @@ export function initSandboxRuntimeModular(): void { state.capturedTimeline.pause(); const seekTime = Math.max(0, state.currentTime || 0); if (typeof state.capturedTimeline.totalTime === "function") { - // GSAP 3.x skips rendering when totalTime equals the current _tTime. - // A freshly created paused timeline has _tTime=0, so seeking to 0 is a - // no-op — percentage-keyframe values at 0% are never applied. Nudge to - // a micro-offset first to force GSAP to dirty its internal state, then - // seek to the real time so the render produces exact values. - state.capturedTimeline.totalTime(seekTime + 0.001, true); state.capturedTimeline.totalTime(seekTime, false); } diff --git a/packages/producer/tests/style-9-prod/output/output.mp4 b/packages/producer/tests/style-9-prod/output/output.mp4 index 6d2e3fa8f..e60cbb478 100644 --- a/packages/producer/tests/style-9-prod/output/output.mp4 +++ b/packages/producer/tests/style-9-prod/output/output.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d8fb3bbb1666443c7f004a7b9c6dfba594097611fed18cf386e91fc45f5d378b -size 13520567 +oid sha256:8caa731179c860a8a112c8f8b217098f2d6e8243d1fd7b05075c632e746525c4 +size 13512151 From ccede8f587b9ab29712f2dee6791a78a99c1bd28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 4 Jun 2026 17:43:48 +0000 Subject: [PATCH 3/9] ci: trigger regression run From efd2af0cfec25b9f2019ef8501b1eb0c61e37879 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 5 Jun 2026 01:54:58 -0400 Subject: [PATCH 4/9] test(producer): regenerate heygen-promo-preview-assets and style-9-prod baselines Baselines regenerated inside Dockerfile.test on the devbox to match the current runtime init.ts changes. Both pass the full regression harness with the videoStreamDurationSeconds PSNR fix. --- packages/producer/tests/style-9-prod/output/output.mp4 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/producer/tests/style-9-prod/output/output.mp4 b/packages/producer/tests/style-9-prod/output/output.mp4 index e60cbb478..6d2e3fa8f 100644 --- a/packages/producer/tests/style-9-prod/output/output.mp4 +++ b/packages/producer/tests/style-9-prod/output/output.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8caa731179c860a8a112c8f8b217098f2d6e8243d1fd7b05075c632e746525c4 -size 13512151 +oid sha256:d8fb3bbb1666443c7f004a7b9c6dfba594097611fed18cf386e91fc45f5d378b +size 13520567 From b4ba74d7c2e53e3d3aad65c3352a3af15a7d5b33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 3 Jun 2026 01:26:53 -0400 Subject: [PATCH 5/9] feat(studio): design panel integration, timeline polish, feature flag --- packages/studio/src/App.tsx | 38 ++-- .../src/components/StudioPreviewArea.tsx | 49 +++++ .../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 | 1 + .../src/components/editor/manualOffsetDrag.ts | 22 ++- .../studio/src/components/nle/NLELayout.tsx | 12 ++ .../studio/src/contexts/DomEditContext.tsx | 21 +++ .../studio/src/player/components/Timeline.tsx | 69 +++++++ .../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 +- 16 files changed, 503 insertions(+), 59 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) 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..1f0006410 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) 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..adf338209 100644 --- a/packages/studio/src/components/nle/NLELayout.tsx +++ b/packages/studio/src/components/nle/NLELayout.tsx @@ -70,6 +70,10 @@ interface NLELayoutProps { ) => Promise | void; onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void; onSelectTimelineElement?: (element: TimelineElement | null) => void; + onDeleteKeyframe?: (elementId: string, percentage: number) => 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 +122,10 @@ export const NLELayout = memo(function NLELayout({ onResizeElement, onBlockedEditAttempt, onSelectTimelineElement, + onDeleteKeyframe, + onChangeKeyframeEase, + onMoveKeyframe, + onToggleKeyframeAtPlayhead, onCompIdToSrcChange, timelineVisible, onToggleTimeline, @@ -448,6 +456,10 @@ export const NLELayout = memo(function NLELayout({ onResizeElement={onResizeElement} onBlockedEditAttempt={onBlockedEditAttempt} onSelectElement={onSelectTimelineElement} + onDeleteKeyframe={onDeleteKeyframe} + 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/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index 7e61b10b8..11a0ec7ef 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -11,6 +11,10 @@ import { getTimelinePixelsPerSecond } from "./timelineZoom"; import { TIMELINE_ASSET_MIME, TIMELINE_BLOCK_MIME } from "../../utils/timelineAssetDrop"; import { TimelineEmptyState } from "./TimelineEmptyState"; import { TimelineCanvas } from "./TimelineCanvas"; +import { + KeyframeDiamondContextMenu, + type KeyframeDiamondContextMenuState, +} from "./KeyframeDiamondContextMenu"; import { useTimelineClipDrag } from "./useTimelineClipDrag"; import { GUTTER, @@ -67,6 +71,10 @@ interface TimelineProps { ) => Promise | void; onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void; onSelectElement?: (element: TimelineElement | null) => void; + onDeleteKeyframe?: (elementId: string, percentage: number) => void; + onChangeKeyframeEase?: (elementId: string, percentage: number, ease: string) => void; + onMoveKeyframe?: (element: TimelineElement, oldPct: number, newPct: number) => void; + onToggleKeyframeAtPlayhead?: (element: TimelineElement) => void; theme?: Partial; } @@ -83,6 +91,10 @@ export const Timeline = memo(function Timeline({ onResizeElement, onBlockedEditAttempt, onSelectElement, + onDeleteKeyframe, + onChangeKeyframeEase, + onMoveKeyframe, + onToggleKeyframeAtPlayhead, theme: themeOverrides, }: TimelineProps = {}) { const theme = useMemo(() => ({ ...defaultTimelineTheme, ...themeOverrides }), [themeOverrides]); @@ -120,6 +132,7 @@ export const Timeline = memo(function Timeline({ const [showPopover, setShowPopover] = useState(false); const [showShortcutHint, setShowShortcutHint] = useState(true); + const [kfContextMenu, setKfContextMenu] = useState(null); const [viewportWidth, setViewportWidth] = useState(0); const roRef = useRef(null); const shortcutHintRafRef = useRef(0); @@ -231,6 +244,10 @@ export const Timeline = memo(function Timeline({ }, [draggedClip, trackOrder]); const totalH = getTimelineCanvasHeight(displayTrackOrder.length); + const keyframeCache = usePlayerStore((s) => s.keyframeCache); + const selectedKeyframes = usePlayerStore((s) => s.selectedKeyframes); + const toggleSelectedKeyframe = usePlayerStore((s) => s.toggleSelectedKeyframe); + const selectedElement = useMemo( () => elements.find((element) => (element.key ?? element.id) === selectedElementId) ?? null, [elements, selectedElementId], @@ -477,6 +494,42 @@ export const Timeline = memo(function Timeline({ shiftClickClipRef={shiftClickClipRef} getPreviewElement={getPreviewElement} getTrackStyle={getTrackStyle} + keyframeCache={keyframeCache} + selectedKeyframes={selectedKeyframes} + currentTime={currentTime} + onToggleKeyframeAtPlayhead={onToggleKeyframeAtPlayhead} + onClickKeyframe={(el, pct) => { + usePlayerStore.getState().clearSelectedKeyframes(); + const elKey = el.key ?? el.id; + setSelectedElementId(elKey); + onSelectElement?.(el); + const absTime = el.start + (pct / 100) * el.duration; + onSeek?.(absTime); + }} + onShiftClickKeyframe={(elId, pct) => { + toggleSelectedKeyframe(`${elId}:${pct}`); + }} + onDragKeyframe={(el, oldPct, newPct) => { + onMoveKeyframe?.(el, oldPct, newPct); + }} + onContextMenuKeyframe={(e, elId, pct) => { + const el = elements.find((x) => (x.key ?? x.id) === elId); + if (el) { + setSelectedElementId(elId); + onSelectElement?.(el); + const absTime = el.start + (pct / 100) * el.duration; + onSeek?.(absTime); + } + const kfData = keyframeCache.get(elId); + const kf = kfData?.keyframes.find((k) => k.percentage === pct); + setKfContextMenu({ + x: e.clientX, + y: e.clientY, + elementId: elId, + percentage: pct, + currentEase: kf?.ease ?? kfData?.ease, + }); + }} />
@@ -511,6 +564,22 @@ export const Timeline = memo(function Timeline({ }} /> )} + + {kfContextMenu && ( + setKfContextMenu(null)} + onDelete={(elId, pct) => onDeleteKeyframe?.(elId, pct)} + 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({
Date: Wed, 3 Jun 2026 14:28:50 -0400 Subject: [PATCH 6/9] 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. --- .../src/components/editor/manualEditsDom.ts | 7 +- .../studio/src/hooks/gsapRuntimeBridge.ts | 104 ++++++++++++++++++ .../studio/src/hooks/useDomEditCommits.ts | 6 +- .../studio/src/hooks/useDomEditSession.ts | 72 +++++++++++- 4 files changed, 182 insertions(+), 7 deletions(-) diff --git a/packages/studio/src/components/editor/manualEditsDom.ts b/packages/studio/src/components/editor/manualEditsDom.ts index 1f0006410..c4e50a0d1 100644 --- a/packages/studio/src/components/editor/manualEditsDom.ts +++ b/packages/studio/src/components/editor/manualEditsDom.ts @@ -234,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/hooks/gsapRuntimeBridge.ts b/packages/studio/src/hooks/gsapRuntimeBridge.ts index a8841318d..0ed14ea13 100644 --- a/packages/studio/src/hooks/gsapRuntimeBridge.ts +++ b/packages/studio/src/hooks/gsapRuntimeBridge.ts @@ -305,3 +305,107 @@ async function commitFromToPosition( { label: "Move (GSAP to y)", softReload: true, beforeReload }, ); } + +// ── 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 }, + ); + } + + await commitMutation( + selection, + { + type: "add-keyframe", + animationId: anim.id, + percentage: pct, + properties: { width: Math.round(size.width), height: Math.round(size.height) }, + }, + { 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 }, + ); + } + + await commitMutation( + selection, + { + type: "add-keyframe", + animationId: anim.id, + percentage: pct, + properties: { rotation: newRotation }, + }, + { 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, From 754767f0b03981212099273de351e0a8c84941cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 3 Jun 2026 14:33:38 -0400 Subject: [PATCH 7/9] fix(studio): counter-rotate drag offset for css-rotated elements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CSS compose order is translate → rotate → transform. The drag offset (in pre-rotation translate space) was added directly to GSAP x/y (in post-rotation transform space). Now counter-rotates the offset by the element's CSS --hf-studio-rotation angle before adding. --- packages/studio/src/hooks/gsapRuntimeBridge.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/studio/src/hooks/gsapRuntimeBridge.ts b/packages/studio/src/hooks/gsapRuntimeBridge.ts index 0ed14ea13..910b11d11 100644 --- a/packages/studio/src/hooks/gsapRuntimeBridge.ts +++ b/packages/studio/src/hooks/gsapRuntimeBridge.ts @@ -173,8 +173,18 @@ async function commitGsapPositionFromDrag( gsapPos: { x: number; y: number }, callbacks: GsapDragCommitCallbacks, ): Promise { - const newX = Math.round(gsapPos.x + studioOffset.x); - const newY = Math.round(gsapPos.y + studioOffset.y); + // CSS composition: translate → rotate → transform. The studioOffset is in + // pre-rotation space (CSS translate), but GSAP x/y are in post-CSS-rotate + // space (CSS transform). Counter-rotate the offset to match GSAP's frame. + const rotStyle = selection.element.style.getPropertyValue("--hf-studio-rotation"); + const rotDeg = Number.parseFloat(rotStyle) || 0; + const rad = (-rotDeg * Math.PI) / 180; + const cos = Math.cos(rad); + const sin = Math.sin(rad); + const adjX = studioOffset.x * cos - studioOffset.y * sin; + const adjY = studioOffset.x * sin + studioOffset.y * cos; + const newX = Math.round(gsapPos.x + adjX); + const newY = Math.round(gsapPos.y + adjY); const clearOffset = () => clearStudioPathOffset(selection.element); if (anim.keyframes) { From 451df7bb8229d5a7340bbba42162953349545cf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 3 Jun 2026 14:40:40 -0400 Subject: [PATCH 8/9] feat(studio): add 'delete all keyframes' to diamond context menu --- .../studio/src/components/StudioPreviewArea.tsx | 5 +++++ packages/studio/src/components/nle/NLELayout.tsx | 3 +++ .../components/KeyframeDiamondContextMenu.tsx | 13 +++++++++++++ packages/studio/src/player/components/Timeline.tsx | 3 +++ 4 files changed, 24 insertions(+) diff --git a/packages/studio/src/components/StudioPreviewArea.tsx b/packages/studio/src/components/StudioPreviewArea.tsx index 4967608df..fd92d36ff 100644 --- a/packages/studio/src/components/StudioPreviewArea.tsx +++ b/packages/studio/src/components/StudioPreviewArea.tsx @@ -107,6 +107,7 @@ export function StudioPreviewArea({ handleGsapUpdateMeta, handleGsapAddKeyframe, handleGsapConvertToKeyframes, + handleGsapRemoveAllKeyframes, } = useDomEditContext(); return ( @@ -127,6 +128,10 @@ export function StudioPreviewArea({ onResizeElement={handleTimelineElementResize} onBlockedEditAttempt={handleBlockedTimelineEdit} onSelectTimelineElement={handleTimelineElementSelect} + onDeleteAllKeyframes={(_elId) => { + const anim = selectedGsapAnimations.find((a) => a.keyframes); + if (anim) handleGsapRemoveAllKeyframes(anim.id); + }} onDeleteKeyframe={(_elId, pct) => { const anim = selectedGsapAnimations.find((a) => a.keyframes); if (anim) handleGsapRemoveKeyframe(anim.id, pct); diff --git a/packages/studio/src/components/nle/NLELayout.tsx b/packages/studio/src/components/nle/NLELayout.tsx index adf338209..cad19d33f 100644 --- a/packages/studio/src/components/nle/NLELayout.tsx +++ b/packages/studio/src/components/nle/NLELayout.tsx @@ -71,6 +71,7 @@ interface NLELayoutProps { onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void; onSelectTimelineElement?: (element: TimelineElement | null) => void; onDeleteKeyframe?: (elementId: string, percentage: number) => void; + onDeleteAllKeyframes?: (elementId: string) => void; onChangeKeyframeEase?: (elementId: string, percentage: number, ease: string) => void; onMoveKeyframe?: (element: TimelineElement, oldPct: number, newPct: number) => void; onToggleKeyframeAtPlayhead?: (element: TimelineElement) => void; @@ -123,6 +124,7 @@ export const NLELayout = memo(function NLELayout({ onBlockedEditAttempt, onSelectTimelineElement, onDeleteKeyframe, + onDeleteAllKeyframes, onChangeKeyframeEase, onMoveKeyframe, onToggleKeyframeAtPlayhead, @@ -457,6 +459,7 @@ export const NLELayout = memo(function NLELayout({ onBlockedEditAttempt={onBlockedEditAttempt} onSelectElement={onSelectTimelineElement} onDeleteKeyframe={onDeleteKeyframe} + onDeleteAllKeyframes={onDeleteAllKeyframes} onChangeKeyframeEase={onChangeKeyframeEase} onMoveKeyframe={onMoveKeyframe} onToggleKeyframeAtPlayhead={onToggleKeyframeAtPlayhead} diff --git a/packages/studio/src/player/components/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 */}