Skip to content

Commit 1a5bb47

Browse files
committed
fix(studio): keyframe commit routing for 3D and cross-group edits
- pickBestAnimation is group-aware: a rotation/3D edit no longer merges into a position tween — a fresh same-group tween with a 0% baseline is created instead - editing at a playhead past the tween extends it and keyframes there (matches drag) - update-keyframe MERGES into the existing keyframe instead of overwriting, so editing one property no longer drops z/transformPerspective (the lens then animated from 0 and the element popped) - dragging a keyframed element with a constant position tween keyframes rather than writing a static set
1 parent 6a729b7 commit 1a5bb47

13 files changed

Lines changed: 466 additions & 124 deletions

packages/core/src/runtime/init.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,23 @@ export function initSandboxRuntimeModular(): void {
9797
swallow("runtime.init.site1", err);
9898
}
9999
}
100+
// `_auto` is a Studio-internal keyframe marker (an auto-tracked endpoint the
101+
// parser reads back), NOT an animatable property. Register it as a no-op GSAP
102+
// plugin so GSAP doesn't log "Invalid property _auto" on every tween build —
103+
// that per-frame warning destabilizes the preview and makes the selection
104+
// overlay stop tracking the pointer. Idempotent + best-effort.
105+
const ensureAutoMarkerNoop = (): void => {
106+
const g = window.gsap as { registerPlugin?: (plugin: unknown) => void } | undefined;
107+
const w = window as Window & { __hfAutoNoopRegistered?: boolean };
108+
if (!g?.registerPlugin || w.__hfAutoNoopRegistered) return;
109+
try {
110+
g.registerPlugin({ name: "_auto", init: () => false });
111+
w.__hfAutoNoopRegistered = true;
112+
} catch {
113+
// a stray warning is preferable to a broken runtime
114+
}
115+
};
116+
ensureAutoMarkerNoop();
100117
// Normalize html/body so browser defaults (8px margin, white background) never
101118
// bleed into renders as white bars. Runs in both preview and render contexts,
102119
// eliminating the preview/render parity gap that existed when only the React

packages/parsers/src/gsapParser.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2348,6 +2348,23 @@ export function updateKeyframeInScript(
23482348
// `properties` would wipe the primitive — leave the keyframe untouched.
23492349
return script;
23502350
}
2351+
// MERGE edited props into the existing keyframe, preserving props not in this edit
2352+
// (z, transformPerspective, rotation, …). A whole-value rebuild drops them, so editing
2353+
// one prop at the 0% keyframe strips z/transformPerspective and the element pops.
2354+
// Mirrors acorn updateKeyframeInScript; parity-locked by gsapWriterParity.corpus.
2355+
const existing = match.prop.value;
2356+
if (existing?.type === "ObjectExpression") {
2357+
const props = (existing.properties ?? []) as AstNode[];
2358+
const upsert = (key: string, valueCode: string) => {
2359+
const idx = props.findIndex((p: AstNode) => isObjectProperty(p) && propKeyName(p) === key);
2360+
const node = parseExpr(`({ ${safeKey(key)}: ${valueCode} })`).properties[0];
2361+
if (idx >= 0) props[idx] = node;
2362+
else props.push(node);
2363+
};
2364+
for (const [k, v] of Object.entries(properties)) upsert(k, valueToCode(v));
2365+
if (ease !== undefined) upsert("ease", JSON.stringify(ease));
2366+
return recast.print(loc.parsed.ast).code;
2367+
}
23512368
match.prop.value = buildKeyframeValueNode(properties, ease);
23522369
return recast.print(loc.parsed.ast).code;
23532370
}

packages/parsers/src/gsapWriterAcorn.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -780,10 +780,22 @@ export function updateKeyframeInScript(
780780
const match = findKfPropByPct(kfPropNode.value, percentage);
781781
if (!match) return script;
782782

783-
const record: Record<string, number | string> = { ...properties };
784-
if (ease) record.ease = ease;
785783
const ms = new MagicString(script);
786-
ms.overwrite(match.prop.value.start, match.prop.value.end, recordToCode(record));
784+
// MERGE the edited props into the existing keyframe, preserving properties already
785+
// keyframed at this percentage (z, transformPerspective, rotation, …). A whole-value
786+
// overwrite DROPS every prop not in this edit — e.g. editing rotationY at the 0%
787+
// keyframe would strip z / transformPerspective, so the lens then animates from 0 and
788+
// the element pops. Mirrors addKeyframeToScript's merge-into-existing branch.
789+
if (match.prop.value?.type === "ObjectExpression") {
790+
for (const [k, v] of Object.entries(properties)) {
791+
upsertProp(ms, match.prop.value, k, v);
792+
}
793+
if (ease !== undefined) upsertProp(ms, match.prop.value, "ease", ease);
794+
} else {
795+
const record: Record<string, number | string> = { ...properties };
796+
if (ease) record.ease = ease;
797+
ms.overwrite(match.prop.value.start, match.prop.value.end, recordToCode(record));
798+
}
787799
return ms.toString();
788800
}
789801

packages/studio/src/components/editor/MotionPathOverlay.tsx

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
elementHome,
2222
hasMotionPathPlugin,
2323
isPreviewHtmlElement,
24+
transformWDivisor,
2425
useMotionPathData,
2526
} from "./useMotionPathData";
2627

@@ -39,6 +40,7 @@ type DragState = {
3940
initX: number;
4041
initY: number;
4142
scale: number;
43+
pScale: number;
4244
ref: MotionNodeRef;
4345
};
4446

@@ -71,7 +73,7 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
7173
handleGsapRemoveKeyframe,
7274
handleGsapDeleteAllForElement,
7375
} = useDomEditContext();
74-
const { rect, geometry, geometryResolved, visibleInPreview, home } = useMotionPathData(
76+
const { rect, geometry, geometryResolved, visibleInPreview, home, pScale } = useMotionPathData(
7577
iframeRef,
7678
selectorFor(selection),
7779
);
@@ -156,8 +158,12 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
156158
e.preventDefault();
157159
const sc = r.width / compW;
158160
const elHome = elementHome(live);
159-
const px = Math.round((e.clientX - r.left) / sc - elHome.x);
160-
const py = Math.round((e.clientY - r.top) / sc - elHome.y);
161+
// De-magnify: the click lands on the projected (1/m44-magnified) path, so
162+
// divide the home-relative offset by the perspective factor to recover the
163+
// stored composition offset (inverse of the `* pScale` applied at draw).
164+
const ps = 1 / transformWDivisor(live);
165+
const px = Math.round(((e.clientX - r.left) / sc - elHome.x) / ps);
166+
const py = Math.round(((e.clientY - r.top) / sc - elHome.y) / ps);
161167
const t = Math.round(usePlayerStore.getState().currentTime * 100) / 100;
162168
void commitCreatePath(createSelector, t, px, py, commitMutation);
163169
setMotionPathArmed(false);
@@ -232,7 +238,16 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
232238
: geometry.nodes;
233239
// ax/ay = absolute composition position (home + offset) for drawing; n.x/n.y
234240
// stay offsets so the drag commit writes the right tween values.
235-
const abs = nodes.map((n) => ({ ...n, ax: home.x + n.x, ay: home.y + n.y }));
241+
// Magnify the animated offsets by the element's perspective factor (1/m44, via
242+
// pScale) so the path tracks the *projected* element. `home` is the projection
243+
// pivot (transform-origin), so it stays put; only the offsets foreshorten. 2D
244+
// elements have pScale = 1 (no change). Inverse (de-magnify) applied wherever a
245+
// pointer position is mapped back to a stored offset (create + node drag).
246+
const abs = nodes.map((n) => ({
247+
...n,
248+
ax: home.x + n.x * pScale,
249+
ay: home.y + n.y * pScale,
250+
}));
236251
const points = abs.map((p) => `${p.ax},${p.ay}`).join(" ");
237252
// Map a VIEWPORT pointer to composition space. Use the iframe's LIVE viewport
238253
// rect, not `rect` — `rect.left/top` are stored pan-surface-relative (for the
@@ -264,6 +279,7 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
264279
initX: x,
265280
initY: y,
266281
scale,
282+
pScale,
267283
ref,
268284
};
269285
setDraft({ index, x, y });
@@ -273,8 +289,8 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
273289
if (!d) return;
274290
setDraft({
275291
index: d.index,
276-
x: d.initX + (e.clientX - d.startX) / d.scale,
277-
y: d.initY + (e.clientY - d.startY) / d.scale,
292+
x: d.initX + (e.clientX - d.startX) / d.scale / d.pScale,
293+
y: d.initY + (e.clientY - d.startY) / d.scale / d.pScale,
278294
});
279295
};
280296
// fallow-ignore-next-line complexity
@@ -286,8 +302,8 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
286302
if (!animId) return;
287303
const screenDx = e.clientX - d.startX;
288304
const screenDy = e.clientY - d.startY;
289-
const x = Math.round(d.initX + screenDx / d.scale);
290-
const y = Math.round(d.initY + screenDy / d.scale);
305+
const x = Math.round(d.initX + screenDx / d.scale / d.pScale);
306+
const y = Math.round(d.initY + screenDy / d.scale / d.pScale);
291307
// Click-vs-drag is decided in SCREEN space, not composition px: the old guard
292308
// compared rounded comp-px, which at high zoom (scale ≫ 1) swallowed real
293309
// multi-px screen drags whose sub-comp-px delta rounds to 0 → the node would

packages/studio/src/components/editor/PropertyPanel.tsx

Lines changed: 38 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,19 @@ export const PropertyPanel = memo(function PropertyPanel({
148148
// eslint-disable-next-line react-hooks/exhaustive-deps
149149
[gsapRuntimeValues, gsapAnimations, element, currentTime],
150150
);
151+
// The 3D Transform panel should be reachable on ANY element, not only ones GSAP is
152+
// already animating — otherwise you can't add depth/rotation to a fresh static
153+
// element (the panel never appears, the classic chicken-and-egg). Default to
154+
// identity when there are no runtime values yet; the first edit creates the
155+
// gsap.set via commitStaticSet, after which real runtime values flow in.
156+
const gsap3dValues: Record<string, number> = gsapRuntimeValues ?? {
157+
rotationX: 0,
158+
rotationY: 0,
159+
rotationZ: 0,
160+
z: 0,
161+
scale: 1,
162+
transformPerspective: 0,
163+
};
151164

152165
if (!element) {
153166
return (
@@ -490,33 +503,31 @@ export const PropertyPanel = memo(function PropertyPanel({
490503
)}
491504
</div>
492505
</div>
493-
{gsapRuntimeValues && (
494-
<PropertyPanel3dTransform
495-
gsapRuntimeValues={gsapRuntimeValues}
496-
gsapAnimId={gsapAnimId}
497-
resolveAnimIdForProp={animIdForProp}
498-
gsapKeyframes={navKeyframes}
499-
currentPct={currentPct}
500-
elStart={elStart}
501-
elDuration={elDuration}
502-
element={element}
503-
onCommitAnimatedProperty={onCommitAnimatedProperty}
504-
onCommitAnimatedProperties={onCommitAnimatedProperties}
505-
onSeekToTime={onSeekToTime}
506-
onRemoveKeyframe={onRemoveKeyframe}
507-
onConvertToKeyframes={onConvertToKeyframes}
508-
onLivePreviewProps={(el, props) => {
509-
const iframe = iframeRef.current;
510-
const win = iframe?.contentWindow as
511-
| { gsap?: { set: (t: Element, v: Record<string, number>) => void } }
512-
| null
513-
| undefined;
514-
const sel = el.id ? `#${el.id}` : el.selector;
515-
const node = sel ? iframe?.contentDocument?.querySelector(sel) : null;
516-
if (win?.gsap && node) win.gsap.set(node, props);
517-
}}
518-
/>
519-
)}
506+
<PropertyPanel3dTransform
507+
gsapRuntimeValues={gsap3dValues}
508+
gsapAnimId={gsapAnimId}
509+
resolveAnimIdForProp={animIdForProp}
510+
gsapKeyframes={navKeyframes}
511+
currentPct={currentPct}
512+
elStart={elStart}
513+
elDuration={elDuration}
514+
element={element}
515+
onCommitAnimatedProperty={onCommitAnimatedProperty}
516+
onCommitAnimatedProperties={onCommitAnimatedProperties}
517+
onSeekToTime={onSeekToTime}
518+
onRemoveKeyframe={onRemoveKeyframe}
519+
onConvertToKeyframes={onConvertToKeyframes}
520+
onLivePreviewProps={(el, props) => {
521+
const iframe = iframeRef.current;
522+
const win = iframe?.contentWindow as
523+
| { gsap?: { set: (t: Element, v: Record<string, number>) => void } }
524+
| null
525+
| undefined;
526+
const sel = el.id ? `#${el.id}` : el.selector;
527+
const node = sel ? iframe?.contentDocument?.querySelector(sel) : null;
528+
if (win?.gsap && node) win.gsap.set(node, props);
529+
}}
530+
/>
520531
<div className="mt-3">
521532
<div className="mb-2 text-[10px] font-medium uppercase tracking-wider text-neutral-600">
522533
Stacking

packages/studio/src/components/editor/Transform3DCube.tsx

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ const pxToProjPersp = (px: number) => (px > 0 ? Math.max(2.2, Math.min(14, px /
2929
export function Transform3DCube({
3030
pose,
3131
perspective = 0,
32+
defaultPerspective = 0,
3233
z = 0,
3334
onPoseDraft,
3435
onPoseCommit,
@@ -41,6 +42,8 @@ export function Transform3DCube({
4142
pose: CubePose;
4243
/** Element's transformPerspective (px); drives the cube's foreshortening. */
4344
perspective?: number;
45+
/** Comp-derived lens used for depth feedback before a perspective is committed. */
46+
defaultPerspective?: number;
4447
/** Element's translateZ (px) — "depth", adjusted by scrolling over the cube. */
4548
z?: number;
4649
/** Fires on every drag move with the in-progress pose (parent live-previews). */
@@ -68,8 +71,12 @@ export function Transform3DCube({
6871
// studio's "scroll = z depth" gesture-recording convention. A non-passive
6972
// listener is required so preventDefault can stop the panel from scrolling.
7073
const svgRef = useRef<SVGSVGElement | null>(null);
71-
const depthRef = useRef({ z, onDepthDraft, onDepthCommit });
72-
depthRef.current = { z, onDepthDraft, onDepthCommit };
74+
// Perspective lens (committed, else the comp-derived default the panel will
75+
// apply). Drives the cube's depth-scale feedback AND clamps the scroll so depth
76+
// can't cross the lens. Defined here so the wheel handler can read it via the ref.
77+
const lens = perspective > 0 ? perspective : defaultPerspective;
78+
const depthRef = useRef({ z, onDepthDraft, onDepthCommit, lens });
79+
depthRef.current = { z, onDepthDraft, onDepthCommit, lens };
7380
useEffect(() => {
7481
const el = svgRef.current;
7582
if (!el) return;
@@ -81,7 +88,14 @@ export function Transform3DCube({
8188
e.preventDefault();
8289
// ponytail: 0.25 px of Z per wheel-delta unit (~25px per notch); tune if
8390
// it feels too fast/slow. Scroll up (deltaY < 0) pushes toward the viewer.
84-
pending = Math.round((pending ?? depthRef.current.z) - e.deltaY * 0.25);
91+
let next = Math.round((pending ?? depthRef.current.z) - e.deltaY * 0.25);
92+
// Clamp depth in front of the perspective lens. At z ≥ lens the element sits
93+
// at/behind the virtual camera and the projection lens/(lens−z) blows up or
94+
// inverts — that's the runaway "Z = 3195px past a 1080 lens". Cap just short
95+
// of the lens; allow pushing well back (smaller) but not absurdly far.
96+
const L = depthRef.current.lens;
97+
if (L > 0) next = Math.max(Math.min(next, Math.round(L * 0.85)), Math.round(-L * 4));
98+
pending = next;
8599
draft?.(pending);
86100
setDepthDraft(pending); // live-scale the cube while scrolling
87101
if (timer) clearTimeout(timer);
@@ -100,15 +114,15 @@ export function Transform3DCube({
100114

101115
// Depth feedback: the cube scales like the element would — translateZ(z) under
102116
// a perspective lens P appears scaled by P/(P-z). Closer (z>0) reads bigger,
103-
// farther (z<0) smaller. Fall back to the default lens so depth always reads in
104-
// the gizmo even before a perspective is set.
105-
const lens = perspective > 0 ? perspective : 800;
106-
const depthScale = Math.max(0.4, Math.min(2.2, lens / (lens - shownZ)));
117+
// farther (z<0) smaller. Use the committed perspective, else the comp-derived
118+
// lens the panel is about to apply — same value in both, so the cube doesn't
119+
// jump when the commit lands. If neither is known, skip the scale (no lens).
120+
const depthScale = lens > 0 ? Math.max(0.4, Math.min(2.2, lens / (lens - shownZ))) : 1;
107121
const projOpts = {
108122
cx: CX,
109123
cy: CY,
110124
r: RADIUS * depthScale,
111-
persp: pxToProjPersp(perspective),
125+
persp: pxToProjPersp(lens),
112126
};
113127
// The element lives in CSS's screen-Y-down space; the cube projects Y-up. RotateX
114128
// and RotateZ act in planes that contain Y, so they read inverted in the gizmo

packages/studio/src/components/editor/manualOffsetDrag.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,22 @@ export function applyManualOffsetDragMatrix(matrix: ManualOffsetDragMatrix, poin
209209
};
210210
}
211211

212+
/**
213+
* The perspective w-divisor (matrix3d m44) of the element's current transform.
214+
* For a plain `translateZ(z)` under `perspective(p)`, m44 = (p - z) / p, so the
215+
* element renders 1/m44× larger and a translate of `d` composition px moves
216+
* `d / m44` px on screen. Returns 1 for 2D transforms (no foreshortening). Used
217+
* to keep the drag offset → screen-movement mapping correct for depth elements,
218+
* which the flat-scale fast path below would otherwise get wrong by 1/m44.
219+
*/
220+
function readTransformWDivisor(element: HTMLElement): number {
221+
const t = element.ownerDocument.defaultView?.getComputedStyle(element).transform;
222+
if (!t || !t.startsWith("matrix3d(")) return 1;
223+
const parts = t.slice("matrix3d(".length, -1).split(",");
224+
const w = Number.parseFloat(parts[15] ?? "");
225+
return Number.isFinite(w) && w > 0 ? w : 1;
226+
}
227+
212228
export function measureManualOffsetDragScreenToOffsetMatrix(
213229
element: HTMLElement,
214230
initialOffset: { x: number; y: number },
@@ -221,7 +237,11 @@ export function measureManualOffsetDragScreenToOffsetMatrix(
221237
) {
222238
const sx = options.scaleX || 1;
223239
const sy = options.scaleY || 1;
224-
return { ok: true, matrix: { a: 1 / sx, b: 0, c: 0, d: 1 / sy } };
240+
// Fold in the perspective foreshortening: a depth element (z≠0) moves
241+
// 1/m44× faster on screen than its flat scale implies, so the screen→offset
242+
// matrix must scale by m44 or the element outruns the pointer/overlay.
243+
const w = readTransformWDivisor(element);
244+
return { ok: true, matrix: { a: w / sx, b: 0, c: 0, d: w / sy } };
225245
}
226246

227247
const probeSize = options.probeSize ?? DEFAULT_OFFSET_PROBE_PX;
@@ -360,6 +380,7 @@ export function createManualOffsetDragMember(input: {
360380
// drag is acceptable — the final committed position is always exact.
361381
const scaleX = input.rect.editScaleX || 1;
362382
const scaleY = input.rect.editScaleY || 1;
383+
const w = readTransformWDivisor(input.element);
363384
return {
364385
ok: true,
365386
member: {
@@ -370,7 +391,7 @@ export function createManualOffsetDragMember(input: {
370391
baseGsap,
371392
initialPathOffset,
372393
gestureToken,
373-
screenToOffset: { a: 1 / scaleX, b: 0, c: 0, d: 1 / scaleY },
394+
screenToOffset: { a: w / scaleX, b: 0, c: 0, d: w / scaleY },
374395
originRect: input.rect,
375396
},
376397
};

0 commit comments

Comments
 (0)