From 0ff20566f02006ade14b79c870fdfba863169336 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Wed, 24 Jun 2026 23:48:48 -0400 Subject: [PATCH 01/33] feat(studio): draggable 3D-transform cube in the design panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Figma-style draggable cube to the 3D Transform section so users can set an element's 3D orientation by dragging instead of typing degrees. Drag tilts the element (rotationX/Y); Shift-drag rolls it (rotationZ); a recenter button resets the 3D transform to identity. The cube previews the orientation live and commits on release. It's an input affordance over the existing keyframe-aware commit path (commitAnimatedProperty) — a drag at the playhead writes/updates keyframes just like the numeric fields, no new mutation infra. - transform3dProjection.ts: pure unit-cube projection with back-face culling and painter ordering (no 3D dependency), unit-tested. - Transform3DCube.tsx: the SVG drag widget (pointer-capture, draft→commit). - Surface the two missing numeric fields (RotZ, Perspective). Perspective drives the new editable `transformPerspective` prop (per-element depth) rather than CSS `perspective` (which only affects children). --- packages/core/src/parsers/gsapConstants.ts | 1 + .../src/components/editor/Transform3DCube.tsx | 118 ++++++++++++++++++ .../editor/gsapAnimationConstants.ts | 5 + .../editor/propertyPanel3dTransform.tsx | 63 ++++++++++ .../editor/transform3dProjection.test.ts | 80 ++++++++++++ .../editor/transform3dProjection.ts | 118 ++++++++++++++++++ 6 files changed, 385 insertions(+) create mode 100644 packages/studio/src/components/editor/Transform3DCube.tsx create mode 100644 packages/studio/src/components/editor/transform3dProjection.test.ts create mode 100644 packages/studio/src/components/editor/transform3dProjection.ts diff --git a/packages/core/src/parsers/gsapConstants.ts b/packages/core/src/parsers/gsapConstants.ts index 5976e4b66..8bea681c7 100644 --- a/packages/core/src/parsers/gsapConstants.ts +++ b/packages/core/src/parsers/gsapConstants.ts @@ -21,6 +21,7 @@ export const SUPPORTED_PROPS = [ "rotationY", "rotationZ", "perspective", + "transformPerspective", "transformOrigin", // Visibility "opacity", diff --git a/packages/studio/src/components/editor/Transform3DCube.tsx b/packages/studio/src/components/editor/Transform3DCube.tsx new file mode 100644 index 000000000..4a5384453 --- /dev/null +++ b/packages/studio/src/components/editor/Transform3DCube.tsx @@ -0,0 +1,118 @@ +import { useRef, useState } from "react"; +import { projectCubeFaces, wrapDeg } from "./transform3dProjection"; + +export interface CubePose { + rotationX: number; + rotationY: number; + rotationZ: number; +} + +const VIEW = 120; +const CENTER = VIEW / 2; +const RADIUS = 30; +const SENSITIVITY = 0.6; // degrees per pixel of drag + +/** + * Draggable 3D-orientation cube. Drag to tilt (X/Y); Shift-drag to roll (Z). + * Presentational only: it renders the pose and emits draft poses while dragging + * plus a final pose on release — the parent owns committing to GSAP props. + */ +export function Transform3DCube({ + pose, + onPoseDraft, + onPoseCommit, + onRecenter, +}: { + pose: CubePose; + /** Fires on every drag move with the in-progress pose (live preview). */ + onPoseDraft?: (pose: CubePose) => void; + /** Fires once on pointer release with the final pose (commit). */ + onPoseCommit: (pose: CubePose) => void; + /** Reset to identity orientation. */ + onRecenter?: () => void; +}) { + const [draft, setDraft] = useState(null); + const dragRef = useRef<{ x: number; y: number; pose: CubePose } | null>(null); + const shown = draft ?? pose; + const faces = projectCubeFaces(shown.rotationX, shown.rotationY, shown.rotationZ, { + cx: CENTER, + cy: CENTER, + r: RADIUS, + }); + + const onPointerDown = (e: React.PointerEvent) => { + e.currentTarget.setPointerCapture(e.pointerId); + dragRef.current = { x: e.clientX, y: e.clientY, pose: shown }; + setDraft(shown); + }; + + const onPointerMove = (e: React.PointerEvent) => { + const d = dragRef.current; + if (!d) return; + const dx = e.clientX - d.x; + const dy = e.clientY - d.y; + const next: CubePose = e.shiftKey + ? { ...d.pose, rotationZ: wrapDeg(d.pose.rotationZ + dx * SENSITIVITY) } + : { + rotationX: wrapDeg(d.pose.rotationX - dy * SENSITIVITY), + rotationY: wrapDeg(d.pose.rotationY + dx * SENSITIVITY), + rotationZ: d.pose.rotationZ, + }; + setDraft(next); + onPoseDraft?.(next); + }; + + const onPointerUp = () => { + if (!dragRef.current) return; + dragRef.current = null; + if (draft) onPoseCommit(draft); + setDraft(null); + }; + + return ( +
+ + {faces.map((f) => ( + + ))} + {/* Center handle dot — the grab indicator. */} + + + {onRecenter && ( + + )} +
+ ); +} diff --git a/packages/studio/src/components/editor/gsapAnimationConstants.ts b/packages/studio/src/components/editor/gsapAnimationConstants.ts index cc8ba04e0..0c97947cf 100644 --- a/packages/studio/src/components/editor/gsapAnimationConstants.ts +++ b/packages/studio/src/components/editor/gsapAnimationConstants.ts @@ -25,6 +25,7 @@ export const PROP_LABELS: Record = { rotationY: "Rotate Y", rotationZ: "Rotate Z", perspective: "Perspective", + transformPerspective: "Perspective", transformOrigin: "Transform Origin", opacity: "Opacity", scale: "Scale", @@ -57,6 +58,7 @@ export const PROP_UNITS: Record = { rotationY: "°", rotationZ: "°", perspective: "px", + transformPerspective: "px", transformOrigin: "", opacity: "%", scale: "×", @@ -80,6 +82,8 @@ export const PROP_TOOLTIPS: Record = { rotationZ: "Rotate around the screen-facing Z axis", perspective: "3D depth context for child elements; set it on a parent when rotating children in 3D", + transformPerspective: + "3D depth for THIS element's own X/Y rotation — lower = stronger perspective (try 600–1000)", transformOrigin: "Pivot point for transforms, for example center center or 50% 50%", width: "Element width", height: "Element height", @@ -150,6 +154,7 @@ export const PROP_CONSTRAINTS: Record resolveAnimIdForProp?.(prop) ?? gsapAnimId; + + const pose: CubePose = { + rotationX: gsapRuntimeValues.rotationX ?? 0, + rotationY: gsapRuntimeValues.rotationY ?? 0, + rotationZ: gsapRuntimeValues.rotationZ ?? 0, + }; + // Commit only the rotation axes the drag actually changed (each rounded to a + // whole degree). Reuses the keyframe-aware animated-property commit, so a drag + // at the playhead writes/updates a keyframe just like the numeric fields. + const commitPose = (next: CubePose) => { + if (!onCommitAnimatedProperty) return; + const axes: Array = ["rotationX", "rotationY", "rotationZ"]; + for (const axis of axes) { + const rounded = Math.round(next[axis]); + if (rounded !== Math.round(pose[axis])) { + void onCommitAnimatedProperty(element, axis, rounded); + } + } + }; + const recenter = () => { + if (!onCommitAnimatedProperty) return; + for (const [prop, identity] of [ + ["rotationX", 0], + ["rotationY", 0], + ["rotationZ", 0], + ["z", 0], + ["scale", 1], + ["transformPerspective", 0], + ] as const) { + void onCommitAnimatedProperty(element, prop, identity); + } + }; + return (
3D Transform
+ {onCommitAnimatedProperty && ( +
+ +

+ Drag to tilt · Shift-drag to roll +

+
+ )}
@@ -142,6 +184,27 @@ export function PropertyPanel3dTransform({ } }} /> + { + const v = Number.parseFloat(next.replace("°", "")); + if (Number.isFinite(v) && onCommitAnimatedProperty) { + void onCommitAnimatedProperty(element, "rotationZ", v); + } + }} + /> + { + const v = parsePxMetricValue(next); + if (v != null && v >= 0 && onCommitAnimatedProperty) { + void onCommitAnimatedProperty(element, "transformPerspective", v); + } + }} + />
); diff --git a/packages/studio/src/components/editor/transform3dProjection.test.ts b/packages/studio/src/components/editor/transform3dProjection.test.ts new file mode 100644 index 000000000..e61f96bd9 --- /dev/null +++ b/packages/studio/src/components/editor/transform3dProjection.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { projectCubeFaces, rotate, wrapDeg } from "./transform3dProjection"; + +const OPTS = { cx: 50, cy: 50, r: 30, persp: 4 }; + +describe("projectCubeFaces", () => { + it("shows the front face (and only front-facing faces) at identity", () => { + const faces = projectCubeFaces(0, 0, 0, OPTS); + const ids = faces.map((f) => f.id); + expect(ids).toContain("front"); + // Head-on: side/top/bottom normals are edge-on (n.z ≈ 0) → culled. + expect(ids).not.toContain("back"); + expect(faces.length).toBeGreaterThanOrEqual(1); + expect(faces.length).toBeLessThanOrEqual(3); + }); + + it("reveals the top face when tilted forward on X", () => { + const ids = projectCubeFaces(45, 0, 0, OPTS).map((f) => f.id); + expect(ids).toContain("front"); + expect(ids).toContain("top"); + expect(ids).not.toContain("bottom"); + }); + + it("reveals a side face when rotated on Y (CSS rotateY convention)", () => { + const ids = projectCubeFaces(0, 45, 0, OPTS).map((f) => f.id); + expect(ids).toContain("front"); + expect(ids).toContain("left"); + expect(ids).not.toContain("right"); + }); + + it("never returns more than 3 faces (a cube shows at most 3 at once)", () => { + for (const [rx, ry, rz] of [ + [30, 30, 0], + [60, 60, 45], + [135, 20, 90], + ]) { + expect(projectCubeFaces(rx, ry, rz, OPTS).length).toBeLessThanOrEqual(3); + } + }); + + it("rotationZ rolls the silhouette without changing which faces are visible", () => { + const a = projectCubeFaces(0, 0, 0, OPTS) + .map((f) => f.id) + .sort(); + const b = projectCubeFaces(0, 0, 90, OPTS) + .map((f) => f.id) + .sort(); + expect(b).toEqual(a); + // …but the projected coordinates differ (it rolled). + expect(projectCubeFaces(0, 0, 90, OPTS)[0]?.points).not.toEqual( + projectCubeFaces(0, 0, 0, OPTS)[0]?.points, + ); + }); + + it("paints far faces before near faces (painter's order)", () => { + const faces = projectCubeFaces(35, 35, 0, OPTS); + for (let i = 1; i < faces.length; i++) { + expect(faces[i]!.depth).toBeGreaterThanOrEqual(faces[i - 1]!.depth); + } + }); +}); + +describe("rotate", () => { + it("90° on Y maps +x to -z (right edge swings to the back)", () => { + const v = rotate({ x: 1, y: 0, z: 0 }, 0, 90, 0); + expect(v.x).toBeCloseTo(0, 5); + expect(v.z).toBeCloseTo(-1, 5); + }); +}); + +describe("wrapDeg", () => { + it("wraps into (-180, 180]", () => { + expect(wrapDeg(0)).toBe(0); + expect(wrapDeg(180)).toBe(180); + expect(wrapDeg(190)).toBe(-170); + expect(wrapDeg(-190)).toBe(170); + expect(wrapDeg(360)).toBe(0); + expect(wrapDeg(540)).toBe(180); + }); +}); diff --git a/packages/studio/src/components/editor/transform3dProjection.ts b/packages/studio/src/components/editor/transform3dProjection.ts new file mode 100644 index 000000000..4f50ce053 --- /dev/null +++ b/packages/studio/src/components/editor/transform3dProjection.ts @@ -0,0 +1,118 @@ +/** + * Pure cube projection for the 3D-transform widget. Given an element's + * rotationX/Y/Z (degrees), project a unit cube to 2D SVG polygons with + * back-face culling and painter's-order sorting — no DOM, no React, so it + * unit-tests in isolation. Used by Transform3DCube to draw a live preview of + * the element's orientation. + */ + +export interface Vec3 { + x: number; + y: number; + z: number; +} + +export interface ProjectedFace { + /** Stable face id (front/back/left/right/top/bottom) for keying + theming. */ + id: string; + /** SVG polygon points: "x,y x,y x,y x,y". */ + points: string; + /** 0..1 brightness from how front-facing the rotated normal is (lighting cue). */ + shade: number; + /** Mean depth of the face's corners; larger = nearer the viewer. */ + depth: number; +} + +const DEG = Math.PI / 180; + +/** Rotate a point intrinsically by X, then Y, then Z (degrees). */ +export function rotate(v: Vec3, rx: number, ry: number, rz: number): Vec3 { + let { x, y, z } = v; + const cx = Math.cos(rx * DEG); + const sx = Math.sin(rx * DEG); + [y, z] = [y * cx - z * sx, y * sx + z * cx]; + const cy = Math.cos(ry * DEG); + const sy = Math.sin(ry * DEG); + [x, z] = [x * cy + z * sy, -x * sy + z * cy]; + const cz = Math.cos(rz * DEG); + const sz = Math.sin(rz * DEG); + [x, y] = [x * cz - y * sz, x * sz + y * cz]; + return { x, y, z }; +} + +// Unit-cube corners (±1) — 0-3 back face (z=-1), 4-7 front face (z=1). +const CORNERS: Vec3[] = [ + { x: -1, y: -1, z: -1 }, + { x: 1, y: -1, z: -1 }, + { x: 1, y: 1, z: -1 }, + { x: -1, y: 1, z: -1 }, + { x: -1, y: -1, z: 1 }, + { x: 1, y: -1, z: 1 }, + { x: 1, y: 1, z: 1 }, + { x: -1, y: 1, z: 1 }, +]; + +const FACES: { id: string; idx: [number, number, number, number]; normal: Vec3 }[] = [ + { id: "front", idx: [4, 5, 6, 7], normal: { x: 0, y: 0, z: 1 } }, + { id: "back", idx: [1, 0, 3, 2], normal: { x: 0, y: 0, z: -1 } }, + { id: "left", idx: [0, 4, 7, 3], normal: { x: -1, y: 0, z: 0 } }, + { id: "right", idx: [5, 1, 2, 6], normal: { x: 1, y: 0, z: 0 } }, + // y=+1 corners project to screen-top (SVG y is flipped), so that face is "top". + { id: "top", idx: [7, 6, 2, 3], normal: { x: 0, y: 1, z: 0 } }, + { id: "bottom", idx: [4, 5, 1, 0], normal: { x: 0, y: -1, z: 0 } }, +]; + +export interface ProjectOpts { + /** Center of the SVG viewport. */ + cx: number; + cy: number; + /** Half-extent of the cube in SVG units (drawn cube ≈ 2·r before perspective). */ + r: number; + /** Weak-perspective strength in units of `r` (larger = flatter; ~3-6 reads as 3D). */ + persp?: number; +} + +function round(n: number): number { + return Math.round(n * 100) / 100; +} + +/** + * Project the cube at the given orientation. Returns only the front-facing + * faces (≤3), painter-sorted far→near so the SVG draws nearer faces on top. + * Screen Y is flipped (SVG y grows downward). + */ +export function projectCubeFaces( + rx: number, + ry: number, + rz: number, + opts: ProjectOpts, +): ProjectedFace[] { + const { cx, cy, r } = opts; + const persp = opts.persp ?? 4; + const rotated = CORNERS.map((c) => rotate(c, rx, ry, rz)); + const faces: ProjectedFace[] = []; + for (const f of FACES) { + const n = rotate(f.normal, rx, ry, rz); + if (n.z <= 1e-6) continue; // back-face cull: normal must point toward viewer + const corners = f.idx.map((i) => rotated[i]!); + const depth = corners.reduce((s, p) => s + p.z, 0) / 4; + const points = corners + .map((p) => { + // Weak perspective: nearer corners (higher z) project slightly larger. + const s = persp / (persp - p.z); + return `${round(cx + p.x * r * s)},${round(cy - p.y * r * s)}`; + }) + .join(" "); + faces.push({ id: f.id, points, shade: 0.45 + n.z * 0.55, depth }); + } + faces.sort((a, b) => a.depth - b.depth); + return faces; +} + +/** Wrap an angle to (-180, 180] so drag accumulation never runs away. */ +export function wrapDeg(deg: number): number { + let d = deg % 360; + if (d > 180) d -= 360; + if (d <= -180) d += 360; + return d; +} From 610cdd5f9e3cda93937afb50e9aded338226dd5b Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 25 Jun 2026 00:01:29 -0400 Subject: [PATCH 02/33] =?UTF-8?q?feat(studio):=20polish=203D=20cube=20?= =?UTF-8?q?=E2=80=94=20collapsed=20by=20default,=20compact=20lit=20cube,?= =?UTF-8?q?=20live=20drag=20preview?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review of the first cut: - 3D Transform section is now collapsible and collapsed by default (it was tall and ate panel space). - Redesign the cube: compact and centered (was full-width), resting isometric camera so it reads as a 3D cube at identity instead of a flat square, directional per-face lighting, gradient backdrop + grounding shadow. - Live element preview while dragging: onLivePreviewProps gsap.sets the live transform on the preview element so it moves WITH the cube; release still commits via the keyframe-aware path. - Extract Cube3dControl to keep the panel component under the complexity gate. --- .../src/components/editor/PropertyPanel.tsx | 10 + .../src/components/editor/Transform3DCube.tsx | 57 +++- .../editor/propertyPanel3dTransform.tsx | 320 +++++++++++------- .../editor/transform3dProjection.ts | 26 +- 4 files changed, 261 insertions(+), 152 deletions(-) diff --git a/packages/studio/src/components/editor/PropertyPanel.tsx b/packages/studio/src/components/editor/PropertyPanel.tsx index 287f91dd1..807801a78 100644 --- a/packages/studio/src/components/editor/PropertyPanel.tsx +++ b/packages/studio/src/components/editor/PropertyPanel.tsx @@ -526,6 +526,16 @@ export const PropertyPanel = memo(function PropertyPanel({ onSeekToTime={onSeekToTime} onRemoveKeyframe={onRemoveKeyframe} onConvertToKeyframes={onConvertToKeyframes} + onLivePreviewProps={(el, props) => { + const iframe = iframeRef.current; + const win = iframe?.contentWindow as + | { gsap?: { set: (t: Element, v: Record) => void } } + | null + | undefined; + const sel = el.id ? `#${el.id}` : el.selector; + const node = sel ? iframe?.contentDocument?.querySelector(sel) : null; + if (win?.gsap && node) win.gsap.set(node, props); + }} /> )}
diff --git a/packages/studio/src/components/editor/Transform3DCube.tsx b/packages/studio/src/components/editor/Transform3DCube.tsx index 4a5384453..53884bc96 100644 --- a/packages/studio/src/components/editor/Transform3DCube.tsx +++ b/packages/studio/src/components/editor/Transform3DCube.tsx @@ -7,15 +7,20 @@ export interface CubePose { rotationZ: number; } -const VIEW = 120; -const CENTER = VIEW / 2; -const RADIUS = 30; +const VIEW_W = 132; +const VIEW_H = 112; +const CX = VIEW_W / 2; +const CY = 54; +const RADIUS = 26; +// Resting isometric camera so the cube reads as 3D at identity. +const VIEW_RX = -20; +const VIEW_RY = 26; const SENSITIVITY = 0.6; // degrees per pixel of drag /** * Draggable 3D-orientation cube. Drag to tilt (X/Y); Shift-drag to roll (Z). - * Presentational only: it renders the pose and emits draft poses while dragging - * plus a final pose on release — the parent owns committing to GSAP props. + * Presentational only: emits a live draft pose while dragging and a final pose + * on release — the parent owns live-previewing and committing to GSAP props. */ export function Transform3DCube({ pose, @@ -24,7 +29,7 @@ export function Transform3DCube({ onRecenter, }: { pose: CubePose; - /** Fires on every drag move with the in-progress pose (live preview). */ + /** Fires on every drag move with the in-progress pose (parent live-previews). */ onPoseDraft?: (pose: CubePose) => void; /** Fires once on pointer release with the final pose (commit). */ onPoseCommit: (pose: CubePose) => void; @@ -35,9 +40,11 @@ export function Transform3DCube({ const dragRef = useRef<{ x: number; y: number; pose: CubePose } | null>(null); const shown = draft ?? pose; const faces = projectCubeFaces(shown.rotationX, shown.rotationY, shown.rotationZ, { - cx: CENTER, - cy: CENTER, + cx: CX, + cy: CY, r: RADIUS, + viewRx: VIEW_RX, + viewRy: VIEW_RY, }); const onPointerDown = (e: React.PointerEvent) => { @@ -70,11 +77,11 @@ export function Transform3DCube({ }; return ( -
+
+ + + + + + + + {/* Grounding shadow under the cube. */} + {faces.map((f) => ( ))} - {/* Center handle dot — the grab indicator. */} - {onRecenter && ( + {collapsed ? null : ( + <> + {onCommitAnimatedProperty && ( + + )} +
+
+
+ { + const v = parsePxMetricValue(next); + if (v != null && onCommitAnimatedProperty) { + void onCommitAnimatedProperty(element, "z", v); + } + }} + /> +
+ {STUDIO_KEYFRAMES_ENABLED && (gsapAnimId || onCommitAnimatedProperty) && ( + onSeekToTime?.(elStart + (pct / 100) * elDuration)} + onAddKeyframe={() => { + if (onCommitAnimatedProperty) { + void onCommitAnimatedProperty(element, "z", gsapRuntimeValues?.z ?? 0); + } + }} + onRemoveKeyframe={(pct) => { + const id = idFor("z"); + if (id) onRemoveKeyframe?.(id, pct); + }} + onConvertToKeyframes={() => { + const id = idFor("z"); + if (id) onConvertToKeyframes?.(id); + }} + /> + )} +
+
+
+ { + const v = Number.parseFloat(next); + if (Number.isFinite(v) && onCommitAnimatedProperty) { + void onCommitAnimatedProperty(element, "scale", v); + } + }} + /> +
+ {STUDIO_KEYFRAMES_ENABLED && (gsapAnimId || onCommitAnimatedProperty) && ( + onSeekToTime?.(elStart + (pct / 100) * elDuration)} + onAddKeyframe={() => { + if (onCommitAnimatedProperty) { + void onCommitAnimatedProperty( + element, + "scale", + gsapRuntimeValues?.scale ?? 1, + ); + } + }} + onRemoveKeyframe={(pct) => { + const id = idFor("scale"); + if (id) onRemoveKeyframe?.(id, pct); + }} + onConvertToKeyframes={() => { + const id = idFor("scale"); + if (id) onConvertToKeyframes?.(id); + }} + /> + )} +
{ - const v = parsePxMetricValue(next); - if (v != null && onCommitAnimatedProperty) { - void onCommitAnimatedProperty(element, "z", v); + const v = Number.parseFloat(next.replace("°", "")); + if (Number.isFinite(v) && onCommitAnimatedProperty) { + void onCommitAnimatedProperty(element, "rotationX", v); } }} /> -
- {STUDIO_KEYFRAMES_ENABLED && (gsapAnimId || onCommitAnimatedProperty) && ( - onSeekToTime?.(elStart + (pct / 100) * elDuration)} - onAddKeyframe={() => { - if (onCommitAnimatedProperty) { - void onCommitAnimatedProperty(element, "z", gsapRuntimeValues?.z ?? 0); + { + const v = Number.parseFloat(next.replace("°", "")); + if (Number.isFinite(v) && onCommitAnimatedProperty) { + void onCommitAnimatedProperty(element, "rotationY", v); } }} - onRemoveKeyframe={(pct) => { - const id = idFor("z"); - if (id) onRemoveKeyframe?.(id, pct); - }} - onConvertToKeyframes={() => { - const id = idFor("z"); - if (id) onConvertToKeyframes?.(id); - }} /> - )} -
-
-
{ - const v = Number.parseFloat(next); + const v = Number.parseFloat(next.replace("°", "")); if (Number.isFinite(v) && onCommitAnimatedProperty) { - void onCommitAnimatedProperty(element, "scale", v); + void onCommitAnimatedProperty(element, "rotationZ", v); } }} /> -
- {STUDIO_KEYFRAMES_ENABLED && (gsapAnimId || onCommitAnimatedProperty) && ( - onSeekToTime?.(elStart + (pct / 100) * elDuration)} - onAddKeyframe={() => { - if (onCommitAnimatedProperty) { - void onCommitAnimatedProperty(element, "scale", gsapRuntimeValues?.scale ?? 1); + { + const v = parsePxMetricValue(next); + if (v != null && v >= 0 && onCommitAnimatedProperty) { + void onCommitAnimatedProperty(element, "transformPerspective", v); } }} - onRemoveKeyframe={(pct) => { - const id = idFor("scale"); - if (id) onRemoveKeyframe?.(id, pct); - }} - onConvertToKeyframes={() => { - const id = idFor("scale"); - if (id) onConvertToKeyframes?.(id); - }} /> - )} -
- { - const v = Number.parseFloat(next.replace("°", "")); - if (Number.isFinite(v) && onCommitAnimatedProperty) { - void onCommitAnimatedProperty(element, "rotationX", v); - } - }} - /> - { - const v = Number.parseFloat(next.replace("°", "")); - if (Number.isFinite(v) && onCommitAnimatedProperty) { - void onCommitAnimatedProperty(element, "rotationY", v); - } - }} - /> - { - const v = Number.parseFloat(next.replace("°", "")); - if (Number.isFinite(v) && onCommitAnimatedProperty) { - void onCommitAnimatedProperty(element, "rotationZ", v); - } - }} - /> - { - const v = parsePxMetricValue(next); - if (v != null && v >= 0 && onCommitAnimatedProperty) { - void onCommitAnimatedProperty(element, "transformPerspective", v); - } - }} - /> -
+
+ + )}
); } diff --git a/packages/studio/src/components/editor/transform3dProjection.ts b/packages/studio/src/components/editor/transform3dProjection.ts index 4f50ce053..e99b857b4 100644 --- a/packages/studio/src/components/editor/transform3dProjection.ts +++ b/packages/studio/src/components/editor/transform3dProjection.ts @@ -70,16 +70,29 @@ export interface ProjectOpts { r: number; /** Weak-perspective strength in units of `r` (larger = flatter; ~3-6 reads as 3D). */ persp?: number; + /** Fixed viewing-camera tilt applied AFTER the element pose, so the cube reads + * as 3D even at identity (an isometric resting pose). Default 0 (head-on). */ + viewRx?: number; + viewRy?: number; } function round(n: number): number { return Math.round(n * 100) / 100; } +// Directional light (upper-front-right), normalized — drives per-face shading so +// top/front/side read as distinct planes instead of one flat fill. +const LIGHT = (() => { + const v = { x: 0.32, y: 0.56, z: 0.76 }; + const m = Math.hypot(v.x, v.y, v.z); + return { x: v.x / m, y: v.y / m, z: v.z / m }; +})(); + /** * Project the cube at the given orientation. Returns only the front-facing * faces (≤3), painter-sorted far→near so the SVG draws nearer faces on top. - * Screen Y is flipped (SVG y grows downward). + * Screen Y is flipped (SVG y grows downward). `shade` is a 0..1 lambert term + * from {@link LIGHT} for clean directional face tones. */ export function projectCubeFaces( rx: number, @@ -89,10 +102,14 @@ export function projectCubeFaces( ): ProjectedFace[] { const { cx, cy, r } = opts; const persp = opts.persp ?? 4; - const rotated = CORNERS.map((c) => rotate(c, rx, ry, rz)); + const vRx = opts.viewRx ?? 0; + const vRy = opts.viewRy ?? 0; + // Element pose first, then the fixed viewing camera. + const view = (v: Vec3) => rotate(rotate(v, rx, ry, rz), vRx, vRy, 0); + const rotated = CORNERS.map(view); const faces: ProjectedFace[] = []; for (const f of FACES) { - const n = rotate(f.normal, rx, ry, rz); + const n = view(f.normal); if (n.z <= 1e-6) continue; // back-face cull: normal must point toward viewer const corners = f.idx.map((i) => rotated[i]!); const depth = corners.reduce((s, p) => s + p.z, 0) / 4; @@ -103,7 +120,8 @@ export function projectCubeFaces( return `${round(cx + p.x * r * s)},${round(cy - p.y * r * s)}`; }) .join(" "); - faces.push({ id: f.id, points, shade: 0.45 + n.z * 0.55, depth }); + const lum = Math.max(0, n.x * LIGHT.x + n.y * LIGHT.y + n.z * LIGHT.z); + faces.push({ id: f.id, points, shade: 0.3 + lum * 0.7, depth }); } faces.sort((a, b) => a.depth - b.depth); return faces; From 47d51e11df1a50bc758c0e7d1888180976294698 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 25 Jun 2026 00:16:29 -0400 Subject: [PATCH 03/33] fix(studio): persist static 3D transform + refine cube edges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cube (and the RotX/RotY numeric fields) didn't stick on an element whose only tween is a position 'set' — commitAnimatedProperty tried to convert the zero-duration hold into keyframes, so the rotation was never written and the cube snapped back. Handle the static-set case: merge the property into the set (update-property) so a static 3D rotation/perspective persists, and the cube reads it back from runtime. Also refine the cube rendering: muted teal lit faces with edges that brighten with how front-facing each face is (crisp bevels, not flat neon outlines), a soft halo glow, and a stronger grounding shadow. --- .../src/components/editor/Transform3DCube.tsx | 49 ++++++++++++------- .../src/hooks/useAnimatedPropertyCommit.ts | 14 ++++++ 2 files changed, 46 insertions(+), 17 deletions(-) diff --git a/packages/studio/src/components/editor/Transform3DCube.tsx b/packages/studio/src/components/editor/Transform3DCube.tsx index 53884bc96..8bfedae92 100644 --- a/packages/studio/src/components/editor/Transform3DCube.tsx +++ b/packages/studio/src/components/editor/Transform3DCube.tsx @@ -93,32 +93,47 @@ export function Transform3DCube({ )}°, Z ${Math.round(shown.rotationZ)}°`} > - - - + + + + {/* Soft halo so the cube floats; SourceGraphic stays crisp on top. */} + + + + + + + {/* Grounding shadow under the cube. */} - {faces.map((f) => ( - - ))} + + {faces.map((f) => ( + + ))} + {onRecenter && ( )} + {onPerspectiveCommit && ( + + )}
); } diff --git a/packages/studio/src/components/editor/propertyPanel3dTransform.tsx b/packages/studio/src/components/editor/propertyPanel3dTransform.tsx index dcc87867b..f5d00e49e 100644 --- a/packages/studio/src/components/editor/propertyPanel3dTransform.tsx +++ b/packages/studio/src/components/editor/propertyPanel3dTransform.tsx @@ -91,8 +91,13 @@ function Cube3dControl({
onLivePreviewProps?.(element, { transformPerspective: px })} + onPerspectiveCommit={(px) => + void onCommitAnimatedProperty(element, "transformPerspective", px) + } onRecenter={recenter} />

@@ -103,6 +108,101 @@ function Cube3dControl({ ); } +interface FieldCtx { + element: DomEditSelection; + gsapRuntimeValues: Record; + gsapKeyframes: KeyframeEntry; + gsapAnimId: string | null; + currentPct: number; + elStart: number; + elDuration: number; + resolveAnimIdForProp?: (prop: string) => string | null; + onCommitAnimatedProperty?: ( + element: DomEditSelection, + property: string, + value: number, + ) => Promise; + onSeekToTime?: (time: number) => void; + onRemoveKeyframe?: (animId: string, pct: number) => void; + onConvertToKeyframes?: (animId: string) => void; +} + +const parseDeg = (s: string): number | null => { + const n = Number.parseFloat(s.replace("°", "")); + return Number.isFinite(n) ? n : null; +}; +const parseScale = (s: string): number | null => { + const n = Number.parseFloat(s); + return Number.isFinite(n) ? n : null; +}; +const parsePxNonNeg = (s: string): number | null => { + const v = parsePxMetricValue(s); + return v != null && v >= 0 ? v : null; +}; + +/** + * One 3D-transform field: a number/scrub input plus its keyframe diamond, so + * rotation / perspective / Z / scale can each be keyframed just like Layout's + * X / Y — the diamond was previously missing on the rotation + perspective rows. + */ +function Transform3dField({ + label, + prop, + scrub, + format, + parse, + defaultValue, + ctx, +}: { + label: string; + prop: string; + scrub?: boolean; + format: (v: number) => string; + parse: (s: string) => number | null; + defaultValue: number; + ctx: FieldCtx; +}) { + const { gsapAnimId, onCommitAnimatedProperty } = ctx; + const idFor = (p: string) => ctx.resolveAnimIdForProp?.(p) ?? gsapAnimId; + const current = ctx.gsapRuntimeValues[prop] ?? defaultValue; + return ( +

+
+ { + const v = parse(next); + if (v != null && onCommitAnimatedProperty) { + void onCommitAnimatedProperty(ctx.element, prop, v); + } + }} + /> +
+ {STUDIO_KEYFRAMES_ENABLED && (gsapAnimId || onCommitAnimatedProperty) && ( + ctx.onSeekToTime?.(ctx.elStart + (pct / 100) * ctx.elDuration)} + onAddKeyframe={() => { + if (onCommitAnimatedProperty) void onCommitAnimatedProperty(ctx.element, prop, current); + }} + onRemoveKeyframe={(pct) => { + const id = idFor(prop); + if (id) ctx.onRemoveKeyframe?.(id, pct); + }} + onConvertToKeyframes={() => { + const id = idFor(prop); + if (id) ctx.onConvertToKeyframes?.(id); + }} + /> + )} +
+ ); +} + export function PropertyPanel3dTransform({ gsapRuntimeValues, gsapAnimId, @@ -118,10 +218,23 @@ export function PropertyPanel3dTransform({ onConvertToKeyframes, onLivePreviewProps, }: PropertyPanel3dTransformProps) { - const idFor = (prop: string) => resolveAnimIdForProp?.(prop) ?? gsapAnimId; // Collapsed by default — the cube + fields are tall, so don't eat panel space // until the user opens 3D. const [collapsed, setCollapsed] = useState(true); + const ctx: FieldCtx = { + element, + gsapRuntimeValues, + gsapKeyframes, + gsapAnimId, + currentPct, + elStart, + elDuration, + resolveAnimIdForProp, + onCommitAnimatedProperty, + onSeekToTime, + onRemoveKeyframe, + onConvertToKeyframes, + }; return (
@@ -146,122 +259,56 @@ export function PropertyPanel3dTransform({ /> )}
-
-
- { - const v = parsePxMetricValue(next); - if (v != null && onCommitAnimatedProperty) { - void onCommitAnimatedProperty(element, "z", v); - } - }} - /> -
- {STUDIO_KEYFRAMES_ENABLED && (gsapAnimId || onCommitAnimatedProperty) && ( - onSeekToTime?.(elStart + (pct / 100) * elDuration)} - onAddKeyframe={() => { - if (onCommitAnimatedProperty) { - void onCommitAnimatedProperty(element, "z", gsapRuntimeValues?.z ?? 0); - } - }} - onRemoveKeyframe={(pct) => { - const id = idFor("z"); - if (id) onRemoveKeyframe?.(id, pct); - }} - onConvertToKeyframes={() => { - const id = idFor("z"); - if (id) onConvertToKeyframes?.(id); - }} - /> - )} -
-
-
- { - const v = Number.parseFloat(next); - if (Number.isFinite(v) && onCommitAnimatedProperty) { - void onCommitAnimatedProperty(element, "scale", v); - } - }} - /> -
- {STUDIO_KEYFRAMES_ENABLED && (gsapAnimId || onCommitAnimatedProperty) && ( - onSeekToTime?.(elStart + (pct / 100) * elDuration)} - onAddKeyframe={() => { - if (onCommitAnimatedProperty) { - void onCommitAnimatedProperty( - element, - "scale", - gsapRuntimeValues?.scale ?? 1, - ); - } - }} - onRemoveKeyframe={(pct) => { - const id = idFor("scale"); - if (id) onRemoveKeyframe?.(id, pct); - }} - onConvertToKeyframes={() => { - const id = idFor("scale"); - if (id) onConvertToKeyframes?.(id); - }} - /> - )} -
- + String(v)} + parse={parseScale} + defaultValue={1} + /> + { - const v = Number.parseFloat(next.replace("°", "")); - if (Number.isFinite(v) && onCommitAnimatedProperty) { - void onCommitAnimatedProperty(element, "rotationX", v); - } - }} + prop="rotationX" + format={(v) => `${v}°`} + parse={parseDeg} + defaultValue={0} /> - { - const v = Number.parseFloat(next.replace("°", "")); - if (Number.isFinite(v) && onCommitAnimatedProperty) { - void onCommitAnimatedProperty(element, "rotationY", v); - } - }} + prop="rotationY" + format={(v) => `${v}°`} + parse={parseDeg} + defaultValue={0} /> - { - const v = Number.parseFloat(next.replace("°", "")); - if (Number.isFinite(v) && onCommitAnimatedProperty) { - void onCommitAnimatedProperty(element, "rotationZ", v); - } - }} + prop="rotationZ" + format={(v) => `${v}°`} + parse={parseDeg} + defaultValue={0} /> - { - const v = parsePxMetricValue(next); - if (v != null && v >= 0 && onCommitAnimatedProperty) { - void onCommitAnimatedProperty(element, "transformPerspective", v); - } - }} + format={formatPxMetricValue} + parse={parsePxNonNeg} + defaultValue={0} />
diff --git a/packages/studio/src/hooks/gsapRuntimePatch.ts b/packages/studio/src/hooks/gsapRuntimePatch.ts index d8d9d5cbe..e00cc8127 100644 --- a/packages/studio/src/hooks/gsapRuntimePatch.ts +++ b/packages/studio/src/hooks/gsapRuntimePatch.ts @@ -20,6 +20,11 @@ export interface SetPatchProps { x?: number; y?: number; rotation?: number; + rotationX?: number; + rotationY?: number; + rotationZ?: number; + z?: number; + transformPerspective?: number; scaleX?: number; scaleY?: number; scale?: number; @@ -37,6 +42,11 @@ const SET_CHANNELS: Array = [ "x", "y", "rotation", + "rotationX", + "rotationY", + "rotationZ", + "z", + "transformPerspective", "scaleX", "scaleY", "scale", diff --git a/packages/studio/src/hooks/useAnimatedPropertyCommit.ts b/packages/studio/src/hooks/useAnimatedPropertyCommit.ts index 84f177eb4..b17166828 100644 --- a/packages/studio/src/hooks/useAnimatedPropertyCommit.ts +++ b/packages/studio/src/hooks/useAnimatedPropertyCommit.ts @@ -12,6 +12,7 @@ import { classifyPropertyGroup } from "@hyperframes/core/gsap-parser"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; import { usePlayerStore } from "../player/store/playerStore"; import { readAllAnimatedProperties, readGsapProperty } from "./gsapRuntimeBridge"; +import type { SetPatchProps } from "./gsapRuntimePatch"; import { selectorFromSelection, computeElementPercentage } from "./gsapShared"; interface CommitAnimatedPropertyDeps { @@ -110,7 +111,21 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) { await gsapCommitMutation( selection, { type: "update-property", animationId: anim.id, property, value }, - { label: `Set ${property}`, softReload: true }, + { + label: `Set ${property}`, + softReload: true, + // Value-only `set` update → patch the live runtime in place (no soft + // reload), so dragging the cube / scrubbing a 3D field is flash-free. + // Falls back to soft reload when the channel isn't patchable. + ...(selector && typeof value === "number" + ? { + instantPatch: { + selector, + change: { kind: "set", props: { [property]: value } as SetPatchProps }, + }, + } + : {}), + }, ); return; } From 927780e265f317c0d7bc6f191ec41177363321f8 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 25 Jun 2026 00:42:30 -0400 Subject: [PATCH 05/33] feat(studio): 3D cube X/Y/Z axis gizmo + gated flash-diagnostic logs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Axis gizmo: render the rotated X (red) / Y (green) / Z (blue) vectors from the cube center — away-facing axes dimmed behind the cube, toward-facing on top with a tip dot + label — so orientation is readable at a glance. - Flash diagnostics: add a gated, JSON-stringified [hf-3d:*] logger (on in dev or via window.__hfDebug). Instruments the commit path (which branch + picked tween), the cube pose/axis commits, and — the key signal — applyPreviewSync's instant-patch-vs-soft-reload decision (a soft reload IS the flash). Reproduce with the console open to pinpoint any remaining flash to a specific commit. --- .../src/components/editor/Transform3DCube.tsx | 52 +++++++++++++++++-- .../editor/propertyPanel3dTransform.tsx | 13 ++++- .../editor/transform3dProjection.test.ts | 21 +++++++- .../editor/transform3dProjection.ts | 45 ++++++++++++++++ .../src/hooks/useAnimatedPropertyCommit.ts | 47 +++++++++++------ .../studio/src/hooks/useGsapScriptCommits.ts | 12 +++++ packages/studio/src/utils/debug3d.ts | 31 +++++++++++ 7 files changed, 201 insertions(+), 20 deletions(-) create mode 100644 packages/studio/src/utils/debug3d.ts diff --git a/packages/studio/src/components/editor/Transform3DCube.tsx b/packages/studio/src/components/editor/Transform3DCube.tsx index ca6e70df7..0cc616aea 100644 --- a/packages/studio/src/components/editor/Transform3DCube.tsx +++ b/packages/studio/src/components/editor/Transform3DCube.tsx @@ -1,5 +1,5 @@ import { useRef, useState } from "react"; -import { projectCubeFaces, wrapDeg } from "./transform3dProjection"; +import { projectAxes, projectCubeFaces, wrapDeg } from "./transform3dProjection"; export interface CubePose { rotationX: number; @@ -114,14 +114,16 @@ export function Transform3DCube({ const [draft, setDraft] = useState(null); const dragRef = useRef<{ x: number; y: number; pose: CubePose } | null>(null); const shown = draft ?? pose; - const faces = projectCubeFaces(shown.rotationX, shown.rotationY, shown.rotationZ, { + const projOpts = { cx: CX, cy: CY, r: RADIUS, viewRx: VIEW_RX, viewRy: VIEW_RY, persp: pxToProjPersp(perspective), - }); + }; + const faces = projectCubeFaces(shown.rotationX, shown.rotationY, shown.rotationZ, projOpts); + const axes = projectAxes(shown.rotationX, shown.rotationY, shown.rotationZ, projOpts); const onPointerDown = (e: React.PointerEvent) => { e.currentTarget.setPointerCapture(e.pointerId); @@ -192,6 +194,22 @@ export function Transform3DCube({ fill="#000" opacity={0.4} /> + {/* Away-facing axes are drawn behind the cube, dimmed. */} + {axes + .filter((a) => !a.front) + .map((a) => ( + + ))} {faces.map((f) => ( ))} + {/* Toward-facing axes on top, with a tip dot + X/Y/Z label. */} + {axes + .filter((a) => a.front) + .map((a) => ( + + + + + {a.id.toUpperCase()} + + + ))} {onRecenter && ( )} + {onKeyframe && ( + + )} {onPerspectiveCommit && ( ; onCommitAnimatedProperty: CommitAnimatedProperty; onLivePreviewProps?: (element: DomEditSelection, props: Record) => void; + onKeyframe?: () => void; + keyframed?: boolean; }) { const pose: CubePose = { rotationX: gsapRuntimeValues.rotationX ?? 0, @@ -110,6 +114,8 @@ function Cube3dControl({ void onCommitAnimatedProperty(element, "transformPerspective", px) } onRecenter={recenter} + onKeyframe={onKeyframe} + keyframed={keyframed} />

Drag to tilt · Shift-drag to roll @@ -269,6 +275,18 @@ export function PropertyPanel3dTransform({ gsapRuntimeValues={gsapRuntimeValues} onCommitAnimatedProperty={onCommitAnimatedProperty} onLivePreviewProps={onLivePreviewProps} + keyframed={(gsapKeyframes ?? []).some( + (kf) => + "rotationX" in kf.properties || + "rotationY" in kf.properties || + "rotationZ" in kf.properties, + )} + onKeyframe={() => { + // Convert the 3D ("other"-group) static set to keyframes so the + // cube can animate; spans the element's clip via elDuration. + const id = resolveAnimIdForProp?.("rotationX") ?? gsapAnimId; + if (id) onConvertToKeyframes?.(id, elDuration); + }} /> )}

From 2eea35a69f4cf70fb61f0c83db8e8556435feb89 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 25 Jun 2026 01:29:53 -0400 Subject: [PATCH 11/33] feat(studio): auto-keyframe 3D transforms on animated elements + stop AssetsTab 404 loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3D transforms now auto-keyframe like drag/resize/rotate: when the element is already animated (its clip has keyframes), editing a 3D prop converts the static set to keyframes so edits at other playheads interpolate — no manual keyframe toggle needed. Purely static elements still write a static set (and the cube's keyframe button remains a manual opt-in for them). Also fix the AssetsTab media-manifest fetch: it was keyed on the assets array reference (new each render) so it re-fetched the (usually missing) manifest on every re-render — spamming 404s and churning the left sidebar during cube drags. Key on a stable join and cache the 404 so a missing manifest is fetched once. --- .../src/components/sidebar/AssetsTab.tsx | 22 +++++++++++++-- .../src/hooks/useAnimatedPropertyCommit.ts | 27 +++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/packages/studio/src/components/sidebar/AssetsTab.tsx b/packages/studio/src/components/sidebar/AssetsTab.tsx index c41d20f28..aaf9daf4d 100644 --- a/packages/studio/src/components/sidebar/AssetsTab.tsx +++ b/packages/studio/src/components/sidebar/AssetsTab.tsx @@ -396,10 +396,25 @@ export const AssetsTab = memo(function AssetsTab({ Map >(new Map()); + // Projects whose media manifest 404'd — most don't have one. Cache the miss so + // we don't re-fetch (and spam the console) on every re-render; the effect was + // also keyed on the `assets` array reference, which changes each render, so it + // re-fired constantly. Key on a stable join + skip known-missing manifests. + const manifest404Ref = useRef>(new Set()); + const assetsKey = assets.join("|"); useEffect(() => { + if (manifest404Ref.current.has(projectId)) return; + let cancelled = false; fetch(`/api/projects/${projectId}/preview/.media/manifest.jsonl`) - .then((r) => (r.ok ? r.text() : "")) + .then((r) => { + if (!r.ok) { + manifest404Ref.current.add(projectId); + return ""; + } + return r.text(); + }) .then((text) => { + if (cancelled || !text) return; const m = new Map< string, { description?: string; duration?: number; width?: number; height?: number } @@ -416,7 +431,10 @@ export const AssetsTab = memo(function AssetsTab({ setManifest(m); }) .catch(() => {}); - }, [projectId, assets]); + return () => { + cancelled = true; + }; + }, [projectId, assetsKey]); const handleDrop = useCallback( (e: React.DragEvent) => { diff --git a/packages/studio/src/hooks/useAnimatedPropertyCommit.ts b/packages/studio/src/hooks/useAnimatedPropertyCommit.ts index 682441b69..bc2163651 100644 --- a/packages/studio/src/hooks/useAnimatedPropertyCommit.ts +++ b/packages/studio/src/hooks/useAnimatedPropertyCommit.ts @@ -81,6 +81,32 @@ function setInstantPatch( return { selector, change: { kind: "set", props: { [property]: value } as SetPatchProps } }; } +/** + * Auto-keyframe a just-updated static `set`: if the element is already animated + * (its clip carries keyframes on another tween), convert the set to keyframes so + * subsequent edits at other playheads interpolate — matching the drag / resize / + * rotate UX. Purely static elements (no other keyframes) are left as a set. + */ +async function maybeAutoKeyframeSet( + selection: DomEditSelection, + setAnim: GsapAnimation, + animations: GsapAnimation[], + commit: NonNullable, +): Promise { + const animatedTween = animations.find((a) => a.keyframes && a.id !== setAnim.id); + if (!animatedTween) return; + log3d("auto-keyframe", { animationId: setAnim.id, duration: animatedTween.duration ?? 1 }); + await commit( + selection, + { + type: "convert-to-keyframes", + animationId: setAnim.id, + duration: animatedTween.duration ?? 1, + }, + { label: "Keyframe 3D transform", softReload: true }, + ); +} + export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) { const { selectedGsapAnimations, @@ -144,6 +170,7 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) { { type: "update-property", animationId: anim.id, property, value }, { label: `Set ${property}`, softReload: true, ...(instantPatch ? { instantPatch } : {}) }, ); + await maybeAutoKeyframeSet(selection, anim, selectedGsapAnimations, gsapCommitMutation); return; } From ad489b557ad81521282ad195f4b0b7e4c608f3df Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 25 Jun 2026 01:47:47 -0400 Subject: [PATCH 12/33] fix(studio): cube writes one keyframe per drag (no duplicate keyframes) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cube committed rotationX/Y/Z as separate add-keyframe mutations; the first axis's auto-keyframe convert shifted the tween so the second axis computed a slightly different percentage → two adjacent keyframes instead of one. Add a batched commitAnimatedProperties that writes all changed props into ONE keyframe, and route the cube through it (commitAnimatedProperty is now a thin single-prop wrapper). Threaded through the panel chain; numeric fields keep the single-prop path. Set-path and keyframe-path extracted to helpers to stay under the complexity gate. --- packages/core/src/runtime/init.test.ts | 43 +++++ packages/core/src/runtime/init.ts | 83 ++++++++- .../src/components/StudioRightPanel.tsx | 2 + .../src/components/editor/PropertyPanel.tsx | 2 + .../editor/propertyPanel3dTransform.tsx | 33 +++- .../components/editor/propertyPanelHelpers.ts | 6 + .../studio/src/contexts/DomEditContext.tsx | 4 + .../src/hooks/useAnimatedPropertyCommit.ts | 172 +++++++++++------- .../studio/src/hooks/useDomEditSession.ts | 2 + .../studio/src/hooks/useGsapAwareEditing.ts | 3 +- 10 files changed, 268 insertions(+), 82 deletions(-) diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index 215c5cada..1b272e794 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -371,6 +371,49 @@ describe("initSandboxRuntimeModular", () => { expect(window.__player?.getDuration()).toBe(12); }); + // #6: a single timeline registered under a key that does NOT match the root's + // data-composition-id must still bind (sole-timeline fallback) instead of + // silently rendering the frozen t=0 DOM. + it("binds the sole registered timeline when its key does not match the root id", () => { + const root = document.createElement("div"); + root.setAttribute("data-composition-id", "main"); + root.setAttribute("data-root", "true"); + root.setAttribute("data-start", "0"); + root.setAttribute("data-width", "1920"); + root.setAttribute("data-height", "1080"); + document.body.appendChild(root); + + // Registered under "wrong-key", not "main". + window.__timelines = { + "wrong-key": createMockTimeline(7), + }; + + initSandboxRuntimeModular(); + + expect(window.__player?.getDuration()).toBe(7); + }); + + // #6: when the root id is missing AND two timelines are registered, the + // fallback is ambiguous, so nothing is bound (the loud warning fires instead). + it("does not bind any timeline when the root id is unmatched and multiple are registered", () => { + const root = document.createElement("div"); + root.setAttribute("data-composition-id", "main"); + root.setAttribute("data-root", "true"); + root.setAttribute("data-start", "0"); + root.setAttribute("data-width", "1920"); + root.setAttribute("data-height", "1080"); + document.body.appendChild(root); + + window.__timelines = { + "wrong-key-a": createMockTimeline(7), + "wrong-key-b": createMockTimeline(9), + }; + + initSandboxRuntimeModular(); + + expect(window.__player?.getDuration()).toBe(0); + }); + it("pauses nested media that is outside the timed-media cache after a seek", () => { const root = document.createElement("div"); root.setAttribute("data-composition-id", "main"); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 599e89c31..1c5e5f82a 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -639,6 +639,29 @@ export function initSandboxRuntimeModular(): void { const resolveRootTimelineFromDocument = (): TimelineResolution => { const timelines = (window.__timelines ?? {}) as Record; + // DX fallback (#6): when the root timeline cannot be resolved by id but + // EXACTLY ONE usable timeline is registered, bind it rather than silently + // rendering the frozen t=0 DOM. Safe because with a single registered + // timeline there is no ambiguity about which one is the composition's + // root. Multiple registered → ambiguous, so we still return null and let + // the loud warning fire. + const resolveSoleTimelineFallback = (reason: string): TimelineResolution => { + const usable = Object.entries(timelines).filter( + (entry): entry is [string, RuntimeTimelineLike] => + !!entry[1] && typeof entry[1].play === "function" && typeof entry[1].pause === "function", + ); + if (usable.length !== 1) return { timeline: null }; + const [soleId, soleTimeline] = usable[0]; + return { + timeline: soleTimeline, + selectedTimelineIds: [soleId], + selectedDurationSeconds: getTimelineDurationSeconds(soleTimeline), + diagnostics: { + code: "root_timeline_sole_registered_fallback", + details: { reason, soleTimelineId: soleId }, + }, + }; + }; const startResolver = createRuntimeStartTimeResolver({ timelineRegistry: timelines, includeAuthoredTimingAttrs: true, @@ -740,7 +763,7 @@ export function initSandboxRuntimeModular(): void { const rootCompositionNode = resolveRootCompositionElement(); const rootCompositionId = rootCompositionNode?.getAttribute("data-composition-id") ?? null; if (!rootCompositionId) { - return { timeline: null }; + return resolveSoleTimelineFallback("root_missing_composition_id"); } const rootTimeline = timelines[rootCompositionId] ?? null; const collectRootChildCandidates = (): Array<{ @@ -1003,7 +1026,7 @@ export function initSandboxRuntimeModular(): void { }; } } - return { timeline: null }; + return resolveSoleTimelineFallback("root_composition_id_unmatched_in_registry"); }; // Track whether child composition timelines have been added to the root. @@ -2102,6 +2125,39 @@ export function initSandboxRuntimeModular(): void { clock.setDuration(boundDuration); } runAdapters("discover", state.currentTime); + // Loud, specific diagnostic for the #1 "looks fine, ships broken" trap: + // a root timeline never bound even though timelines ARE registered. Without + // this the render silently proceeds on the static build-time DOM (frozen at + // t=0). Only warn when GSAP timelines exist (CSS/WAAPI/Lottie-only + // compositions legitimately bind no GSAP timeline and use adapters). + if (!state.capturedTimeline) { + const registry = (window.__timelines ?? {}) as Record; + const registeredKeys = Object.keys(registry).filter((k) => registry[k]); + if (registeredKeys.length > 0) { + const rootEl = resolveRootCompositionElement(); + const rootCompositionId = rootEl?.getAttribute("data-composition-id") ?? null; + postRuntimeDiagnosticOnce( + "root_timeline_unbound_registry_present", + { + reason: rootCompositionId + ? "root data-composition-id has no matching key in window.__timelines" + : "root composition element has no data-composition-id attribute", + rootCompositionId, + registeredTimelineKeys: registeredKeys, + }, + "root_timeline_unbound_registry_present", + ); + // eslint-disable-next-line no-console -- loud author-facing warning; this render would otherwise freeze at t=0 + console.warn( + `[hyperframes] Root timeline not bound — render will freeze at t=0. ` + + (rootCompositionId + ? `Root data-composition-id is "${rootCompositionId}" but window.__timelines has no such key. ` + : `Root composition element has no data-composition-id. `) + + `Registered timeline keys: [${registeredKeys.join(", ")}]. ` + + `Register the root timeline under its data-composition-id (window.__timelines["${rootCompositionId ?? ""}"] = tl).`, + ); + } + } // __renderReady = timeline binding attempted, safe for deterministic seeking. // Set after any GSAP batching has completed. renderSeek works with or // without a GSAP timeline (CSS/WAAPI/Lottie compositions use adapters only). @@ -2232,11 +2288,30 @@ export function initSandboxRuntimeModular(): void { if (opts?.activateChildren) { activateSiblingTimelines(tl); } + // #10: when data-duration exceeds the timeline's intrinsic length the + // engine requests frames past the last tween. Seeking a paused GSAP + // timeline past its end can revert from()-tweens to their empty initial + // state, blanking the final poster. Clamp the MASTER seek to the + // timeline's full extent so it holds the final computed frame instead. + // Adapters still receive the raw `t` (their media may run longer). + // totalDuration() includes repeats; Infinity (infinite repeat) → no clamp. + const tlWithTotal = tl as RuntimeTimelineLike & { totalDuration?: () => number }; + let tlSeekTime = t; + if (typeof tlWithTotal.totalDuration === "function") { + try { + const total = Number(tlWithTotal.totalDuration()); + if (Number.isFinite(total) && total > 0 && t > total) { + tlSeekTime = total; + } + } catch (err) { + swallow("runtime.init.transport.clampDuration", err); + } + } try { if (typeof tl.totalTime === "function") { - tl.totalTime(t, false); + tl.totalTime(tlSeekTime, false); } else { - tl.seek(t, false); + tl.seek(tlSeekTime, false); } } catch (err) { swallow("runtime.init.transport.seek", err); diff --git a/packages/studio/src/components/StudioRightPanel.tsx b/packages/studio/src/components/StudioRightPanel.tsx index eb75aaef0..07df9b84f 100644 --- a/packages/studio/src/components/StudioRightPanel.tsx +++ b/packages/studio/src/components/StudioRightPanel.tsx @@ -118,6 +118,7 @@ export function StudioRightPanel({ handleGsapAddFromProperty, handleGsapRemoveFromProperty, commitAnimatedProperty, + commitAnimatedProperties, handleSetArcPath, handleUpdateArcSegment, handleUnroll, @@ -269,6 +270,7 @@ export function StudioRightPanel({ onRemoveGsapFromProperty={handleGsapRemoveFromProperty} onAddGsapAnimation={handleGsapAddAnimation} onCommitAnimatedProperty={commitAnimatedProperty} + onCommitAnimatedProperties={commitAnimatedProperties} onAddKeyframe={handleGsapAddKeyframe} onRemoveKeyframe={handleGsapRemoveKeyframe} onConvertToKeyframes={(animId, duration) => diff --git a/packages/studio/src/components/editor/PropertyPanel.tsx b/packages/studio/src/components/editor/PropertyPanel.tsx index 807801a78..17bb2c53e 100644 --- a/packages/studio/src/components/editor/PropertyPanel.tsx +++ b/packages/studio/src/components/editor/PropertyPanel.tsx @@ -90,6 +90,7 @@ export const PropertyPanel = memo(function PropertyPanel({ onRemoveKeyframe, onConvertToKeyframes, onCommitAnimatedProperty, + onCommitAnimatedProperties, onSeekToTime, recordingState, recordingDuration, @@ -523,6 +524,7 @@ export const PropertyPanel = memo(function PropertyPanel({ elDuration={elDuration} element={element} onCommitAnimatedProperty={onCommitAnimatedProperty} + onCommitAnimatedProperties={onCommitAnimatedProperties} onSeekToTime={onSeekToTime} onRemoveKeyframe={onRemoveKeyframe} onConvertToKeyframes={onConvertToKeyframes} diff --git a/packages/studio/src/components/editor/propertyPanel3dTransform.tsx b/packages/studio/src/components/editor/propertyPanel3dTransform.tsx index 4f2884d03..f656d3656 100644 --- a/packages/studio/src/components/editor/propertyPanel3dTransform.tsx +++ b/packages/studio/src/components/editor/propertyPanel3dTransform.tsx @@ -27,6 +27,11 @@ interface PropertyPanel3dTransformProps { property: string, value: number, ) => Promise; + /** Batched commit — several props into one keyframe (the cube's rotationX/Y/Z). */ + onCommitAnimatedProperties?: ( + element: DomEditSelection, + props: Record, + ) => Promise; onSeekToTime?: (time: number) => void; onRemoveKeyframe?: (animId: string, pct: number) => void; onConvertToKeyframes?: (animId: string, duration?: number) => void; @@ -45,6 +50,7 @@ function Cube3dControl({ element, gsapRuntimeValues, onCommitAnimatedProperty, + onCommitAnimatedProperties, onLivePreviewProps, onKeyframe, keyframed, @@ -52,6 +58,10 @@ function Cube3dControl({ element: DomEditSelection; gsapRuntimeValues: Record; onCommitAnimatedProperty: CommitAnimatedProperty; + onCommitAnimatedProperties?: ( + element: DomEditSelection, + props: Record, + ) => Promise; onLivePreviewProps?: (element: DomEditSelection, props: Record) => void; onKeyframe?: () => void; keyframed?: boolean; @@ -65,20 +75,27 @@ function Cube3dControl({ // whole degree). Reuses the keyframe-aware animated-property commit, so a drag // at the playhead writes/updates a keyframe just like the numeric fields. const commitPose = (next: CubePose) => { - const changed: string[] = []; + const changedProps: Record = {}; for (const axis of ["rotationX", "rotationY", "rotationZ"] as const) { const rounded = Math.round(next[axis]); - if (rounded !== Math.round(pose[axis])) { - changed.push(`${axis}=${rounded}`); - onCommitAnimatedProperty(element, axis, rounded); - } + if (rounded !== Math.round(pose[axis])) changedProps[axis] = rounded; } + const axes = Object.keys(changedProps); log3d("cube-commit-pose", { from: pose, to: next, - changedAxes: changed, - commits: changed.length, + changedAxes: axes, + batched: !!onCommitAnimatedProperties, }); + if (axes.length === 0) return; + // ONE keyframe for the whole pose change — avoids per-axis commits racing into + // adjacent duplicate keyframes. Fall back to per-axis if no batched commit. + if (onCommitAnimatedProperties) { + void onCommitAnimatedProperties(element, changedProps); + } else { + for (const [axis, v] of Object.entries(changedProps)) + onCommitAnimatedProperty(element, axis, v); + } }; const recenter = () => { for (const [prop, identity] of [ @@ -232,6 +249,7 @@ export function PropertyPanel3dTransform({ elDuration, element, onCommitAnimatedProperty, + onCommitAnimatedProperties, onSeekToTime, onRemoveKeyframe, onConvertToKeyframes, @@ -274,6 +292,7 @@ export function PropertyPanel3dTransform({ element={element} gsapRuntimeValues={gsapRuntimeValues} onCommitAnimatedProperty={onCommitAnimatedProperty} + onCommitAnimatedProperties={onCommitAnimatedProperties} onLivePreviewProps={onLivePreviewProps} keyframed={(gsapKeyframes ?? []).some( (kf) => diff --git a/packages/studio/src/components/editor/propertyPanelHelpers.ts b/packages/studio/src/components/editor/propertyPanelHelpers.ts index 10af52522..3b8c84a3d 100644 --- a/packages/studio/src/components/editor/propertyPanelHelpers.ts +++ b/packages/studio/src/components/editor/propertyPanelHelpers.ts @@ -74,6 +74,12 @@ export interface PropertyPanelProps { property: string, value: number | string, ) => Promise; + /** Batched variant: commit several props into ONE keyframe (e.g. the 3D cube's + * rotationX/Y/Z) so multi-axis edits don't race into adjacent duplicates. */ + onCommitAnimatedProperties?: ( + selection: DomEditSelection, + props: Record, + ) => Promise; onSeekToTime?: (time: number) => void; recordingState?: "idle" | "recording" | "preview"; recordingDuration?: number; diff --git a/packages/studio/src/contexts/DomEditContext.tsx b/packages/studio/src/contexts/DomEditContext.tsx index 7644f3069..0222a9bff 100644 --- a/packages/studio/src/contexts/DomEditContext.tsx +++ b/packages/studio/src/contexts/DomEditContext.tsx @@ -55,6 +55,7 @@ export interface DomEditActionsValue extends Pick< | "handleGsapRemoveAllKeyframes" | "handleResetSelectedElementKeyframes" | "commitAnimatedProperty" + | "commitAnimatedProperties" | "handleSetArcPath" | "handleUpdateArcSegment" | "handleUnroll" @@ -164,6 +165,7 @@ export function DomEditProvider({ handleGsapRemoveAllKeyframes, handleResetSelectedElementKeyframes, commitAnimatedProperty, + commitAnimatedProperties, handleSetArcPath, handleUpdateArcSegment, handleUnroll, @@ -238,6 +240,7 @@ export function DomEditProvider({ handleGsapRemoveAllKeyframes, handleResetSelectedElementKeyframes, commitAnimatedProperty, + commitAnimatedProperties, handleSetArcPath, handleUpdateArcSegment, handleUnroll, @@ -298,6 +301,7 @@ export function DomEditProvider({ handleGsapRemoveAllKeyframes, handleResetSelectedElementKeyframes, commitAnimatedProperty, + commitAnimatedProperties, handleSetArcPath, handleUpdateArcSegment, handleUnroll, diff --git a/packages/studio/src/hooks/useAnimatedPropertyCommit.ts b/packages/studio/src/hooks/useAnimatedPropertyCommit.ts index bc2163651..9d31ddab3 100644 --- a/packages/studio/src/hooks/useAnimatedPropertyCommit.ts +++ b/packages/studio/src/hooks/useAnimatedPropertyCommit.ts @@ -107,6 +107,75 @@ async function maybeAutoKeyframeSet( ); } +type Commit = NonNullable; + +/** Merge each prop into the static `set` (value-only, instant), then auto-keyframe. */ +async function commitSetProps( + selection: DomEditSelection, + setAnim: GsapAnimation, + propEntries: [string, number | string][], + selector: string | null, + animations: GsapAnimation[], + commit: Commit, +): Promise { + for (const [property, value] of propEntries) { + const instantPatch = setInstantPatch(selector, property, value); + await commit( + selection, + { type: "update-property", animationId: setAnim.id, property, value }, + { label: `Set ${property}`, softReload: true, ...(instantPatch ? { instantPatch } : {}) }, + ); + } + await maybeAutoKeyframeSet(selection, setAnim, animations, commit); +} + +/** Convert-if-flat, then write ALL props into ONE keyframe at the playhead. */ +async function commitKeyframeProps( + selection: DomEditSelection, + anim: GsapAnimation, + props: Record, + propEntries: [string, number | string][], + primaryProp: string, + selector: string | null, + iframe: HTMLIFrameElement | null, + commit: Commit, +): Promise { + if (!anim.keyframes) { + await commit( + selection, + { type: "convert-to-keyframes", animationId: anim.id }, + { label: "Convert to keyframes", skipReload: true }, + ); + } + const pct = computeElementPercentage(usePlayerStore.getState().currentTime, selection, anim); + const runtimeProps = selector ? readAllAnimatedProperties(iframe, selector, anim) : {}; + const properties: Record = { ...runtimeProps, ...props }; + + const backfillDefaults: Record = { ...runtimeProps }; + for (const [property, value] of propEntries) { + if (!(property in runtimeProps) && selector) { + const cssVal = readGsapProperty(iframe, selector, property); + if (cssVal != null) backfillDefaults[property] = cssVal; + } + backfillDefaults[property] = value; + } + + const existingKf = anim.keyframes?.keyframes.some((kf) => Math.abs(kf.percentage - pct) < 0.05); + await commit( + selection, + existingKf + ? { type: "update-keyframe", animationId: anim.id, percentage: pct, properties } + : { + type: "add-keyframe", + animationId: anim.id, + percentage: pct, + properties, + backfillDefaults, + }, + { label: `Edit ${primaryProp} (keyframe ${pct}%)`, softReload: true }, + ); +} + export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) { const { selectedGsapAnimations, @@ -116,25 +185,23 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) { bumpGsapCache, } = deps; - const commitAnimatedProperty = useCallback( - async ( - selection: DomEditSelection, - property: string, - value: number | string, - ): Promise => { + const commitAnimatedProperties = useCallback( + async (selection: DomEditSelection, props: Record): Promise => { if (!gsapCommitMutation) return; + const propEntries = Object.entries(props); + if (propEntries.length === 0) return; + const primaryProp = propEntries[0]![0]; const iframe = previewIframeRef.current; const selector = selectorFromSelection(selection); - let anim: GsapAnimation | undefined = pickBestAnimation( + const anim: GsapAnimation | undefined = pickBestAnimation( selectedGsapAnimations, selector, - property, + primaryProp, ); log3d("commit-prop", { - property, - value, + props, selector, pickedAnim: anim ? { id: anim.id, method: anim.method, hasKeyframes: !!anim.keyframes } @@ -155,76 +222,41 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) { return; } - // Case 2b: Static hold — the best tween is a zero-duration `set` (e.g. a - // position hold on an un-animated element). Merge the property INTO that set - // so it persists as a static value, instead of converting the hold into a - // keyframed animation. This is what makes a static 3D rotation / perspective - // stick on an element that was only ever moved, not animated. + // Case 2b: Static hold — merge the props into the `set` (persist as static), + // then auto-keyframe if the element is already animated. if (anim.method === "set") { - // Value-only `set` update → patch the live runtime in place (no soft - // reload), so dragging the cube / scrubbing a 3D field is flash-free. - // Falls back to soft reload when the channel isn't patchable. - const instantPatch = setInstantPatch(selector, property, value); - await gsapCommitMutation( + await commitSetProps( selection, - { type: "update-property", animationId: anim.id, property, value }, - { label: `Set ${property}`, softReload: true, ...(instantPatch ? { instantPatch } : {}) }, + anim, + propEntries, + selector, + selectedGsapAnimations, + gsapCommitMutation, ); - await maybeAutoKeyframeSet(selection, anim, selectedGsapAnimations, gsapCommitMutation); return; } - // Case 2: Flat animation — convert to keyframes first - if (!anim.keyframes) { - await gsapCommitMutation( - selection, - { type: "convert-to-keyframes", animationId: anim.id }, - { label: "Convert to keyframes", skipReload: true }, - ); - } - - const pct = computeElementPercentage(usePlayerStore.getState().currentTime, selection, anim); - - // Read all currently animated properties from runtime for backfill - const runtimeProps = selector ? readAllAnimatedProperties(iframe, selector, anim) : {}; - - // Build the properties object: all runtime props + the new value - const properties: Record = { ...runtimeProps }; - properties[property] = value; - - // Compute backfill defaults for properties not in existing keyframes - const backfillDefaults: Record = { ...runtimeProps }; - if (!(property in runtimeProps) && selector) { - const cssVal = readGsapProperty(iframe, selector, property); - if (cssVal != null) backfillDefaults[property] = cssVal; - } - backfillDefaults[property] = typeof value === "number" ? value : value; - - const existingKf = anim.keyframes?.keyframes.some( - (kf) => Math.abs(kf.percentage - pct) < 0.05, - ); - - await gsapCommitMutation( + // Cases 1 & 2: keyframed (or flat → convert) — write ALL props into ONE + // keyframe so a multi-axis cube edit doesn't race into adjacent duplicates. + await commitKeyframeProps( selection, - existingKf - ? { - type: "update-keyframe", - animationId: anim.id, - percentage: pct, - properties, - } - : { - type: "add-keyframe", - animationId: anim.id, - percentage: pct, - properties, - backfillDefaults, - }, - { label: `Edit ${property} (keyframe ${pct}%)`, softReload: true }, + anim, + props, + propEntries, + primaryProp, + selector, + iframe, + gsapCommitMutation, ); }, [selectedGsapAnimations, gsapCommitMutation, addGsapAnimation, previewIframeRef, bumpGsapCache], ); - return commitAnimatedProperty; + const commitAnimatedProperty = useCallback( + (selection: DomEditSelection, property: string, value: number | string) => + commitAnimatedProperties(selection, { [property]: value }), + [commitAnimatedProperties], + ); + + return { commitAnimatedProperty, commitAnimatedProperties }; } diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index 736d97330..28c5288ac 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -371,6 +371,7 @@ export function useDomEditSession({ handleGsapAwareBoxSizeCommit, handleGsapAwareRotationCommit, commitAnimatedProperty, + commitAnimatedProperties, handleSetArcPath, handleUpdateArcSegment, handleUnroll, @@ -498,6 +499,7 @@ export function useDomEditSession({ handleUpdateKeyframeEase, handleSetAllKeyframeEases, commitAnimatedProperty, + commitAnimatedProperties, handleSetArcPath, handleUpdateArcSegment, handleUnroll, diff --git a/packages/studio/src/hooks/useGsapAwareEditing.ts b/packages/studio/src/hooks/useGsapAwareEditing.ts index da3f50036..21d74ebd6 100644 --- a/packages/studio/src/hooks/useGsapAwareEditing.ts +++ b/packages/studio/src/hooks/useGsapAwareEditing.ts @@ -184,7 +184,7 @@ export function useGsapAwareEditing({ // ── Animated property commit ── - const commitAnimatedProperty = useAnimatedPropertyCommit({ + const { commitAnimatedProperty, commitAnimatedProperties } = useAnimatedPropertyCommit({ selectedGsapAnimations, gsapCommitMutation, addGsapAnimation: (sel, method, time) => addGsapAnimation(sel, method, time), @@ -249,6 +249,7 @@ export function useGsapAwareEditing({ handleGsapAwareBoxSizeCommit, handleGsapAwareRotationCommit, commitAnimatedProperty, + commitAnimatedProperties, handleSetArcPath, handleUpdateArcSegment, handleUnroll, From 5964df08c86ebf659c4dcfb3bb7108ac108e5a56 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 25 Jun 2026 02:21:29 -0400 Subject: [PATCH 13/33] refactor(studio): extract AudioRow from AssetsTab to satisfy file-size check The manifest-404 fix touched AssetsTab.tsx, which was already over the 600-line cap (702). Move the self-contained AudioRow sub-component to its own file, bringing AssetsTab to 493 lines. --- .../src/components/sidebar/AssetsTab.tsx | 213 +----------------- .../src/components/sidebar/AudioRow.tsx | 213 ++++++++++++++++++ 2 files changed, 215 insertions(+), 211 deletions(-) create mode 100644 packages/studio/src/components/sidebar/AudioRow.tsx diff --git a/packages/studio/src/components/sidebar/AssetsTab.tsx b/packages/studio/src/components/sidebar/AssetsTab.tsx index aaf9daf4d..552272f6a 100644 --- a/packages/studio/src/components/sidebar/AssetsTab.tsx +++ b/packages/studio/src/components/sidebar/AssetsTab.tsx @@ -3,17 +3,17 @@ import { VideoFrameThumbnail } from "../ui/VideoFrameThumbnail"; import { MEDIA_EXT, IMAGE_EXT, VIDEO_EXT, FONT_EXT } from "../../utils/mediaTypes"; import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop"; import { copyTextToClipboard } from "../../utils/clipboard"; -import { ContextMenu, DeleteConfirm } from "./AssetContextMenu"; +import { ContextMenu } from "./AssetContextMenu"; import { usePlayerStore } from "../../player/store/playerStore"; import { type MediaCategory, getCategory, - getAudioSubtype, basename, ext, CATEGORY_LABELS, FILTER_ORDER, } from "./assetHelpers"; +import { AudioRow } from "./AudioRow"; interface AssetsTabProps { projectId: string; @@ -23,215 +23,6 @@ interface AssetsTabProps { onRename?: (oldPath: string, newPath: string) => void; } -function AudioRow({ - projectId, - asset, - used, - meta, - onCopy, - isCopied, - onDelete, - onRename, -}: { - projectId: string; - asset: string; - used: boolean; - meta?: { description?: string; duration?: number }; - onCopy: (path: string) => void; - isCopied: boolean; - onDelete?: (path: string) => void; - onRename?: (oldPath: string, newPath: string) => void; -}) { - const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); - const [confirmDelete, setConfirmDelete] = useState(false); - const [playing, setPlaying] = useState(false); - const [bars, setBars] = useState([]); - const audioRef = useRef(null); - const actxRef = useRef(null); - const analyserRef = useRef(null); - const sourceRef = useRef(null); - const animRef = useRef(0); - const name = basename(asset); - const subtype = getAudioSubtype(asset); - const serveUrl = `/api/projects/${projectId}/preview/${asset}`; - - useEffect(() => { - return () => { - cancelAnimationFrame(animRef.current); - audioRef.current?.pause(); - actxRef.current?.close(); - }; - }, []); - - useEffect(() => { - if (playing) { - const barCount = 24; - const loop = () => { - const analyser = analyserRef.current; - if (!analyser) { - animRef.current = requestAnimationFrame(loop); - return; - } - const data = new Uint8Array(analyser.frequencyBinCount); - analyser.getByteFrequencyData(data); - const step = Math.floor(data.length / barCount); - const next: number[] = []; - for (let i = 0; i < barCount; i++) { - let sum = 0; - for (let j = 0; j < step; j++) sum += data[i * step + j]; - next.push(sum / step / 255); - } - setBars(next); - if (audioRef.current && !audioRef.current.paused) - animRef.current = requestAnimationFrame(loop); - }; - animRef.current = requestAnimationFrame(loop); - } else { - setBars([]); - } - return () => cancelAnimationFrame(animRef.current); - }, [playing]); - - const togglePlay = useCallback(async () => { - if (playing) { - audioRef.current?.pause(); - setPlaying(false); - cancelAnimationFrame(animRef.current); - return; - } - - if (!actxRef.current) { - actxRef.current = new AudioContext(); - analyserRef.current = actxRef.current.createAnalyser(); - analyserRef.current.fftSize = 256; - analyserRef.current.smoothingTimeConstant = 0.7; - } - - if (!audioRef.current) { - const el = new Audio(); - el.onended = () => { - setPlaying(false); - cancelAnimationFrame(animRef.current); - }; - audioRef.current = el; - sourceRef.current = actxRef.current.createMediaElementSource(el); - sourceRef.current.connect(analyserRef.current!); - analyserRef.current!.connect(actxRef.current.destination); - el.src = serveUrl; - } - - if (actxRef.current.state === "suspended") await actxRef.current.resume(); - audioRef.current.currentTime = 0; - await audioRef.current.play(); - setPlaying(true); - }, [serveUrl, playing]); - - return ( - <> -
onCopy(asset)} - onDragStart={(e) => { - e.dataTransfer.effectAllowed = "copy"; - e.dataTransfer.setData(TIMELINE_ASSET_MIME, JSON.stringify({ path: asset })); - e.dataTransfer.setData("text/plain", asset); - }} - onContextMenu={(e) => { - e.preventDefault(); - setContextMenu({ x: e.clientX, y: e.clientY }); - }} - className={`group w-full text-left px-4 py-1.5 flex items-center gap-2.5 transition-all cursor-pointer ${ - playing - ? "bg-panel-accent/[0.06]" - : isCopied - ? "bg-panel-accent/10" - : "hover:bg-panel-surface-hover" - }`} - > - -
-
- - {name} - - {!playing && ( - - {meta?.duration ? `${meta.duration}s · ` : ""} - {subtype} - - )} - {used && ( - - in use - - )} -
- {bars.length > 0 && ( -
- {bars.map((v, i) => ( -
- ))} -
- )} -
-
- - {contextMenu && ( - setContextMenu(null)} - onCopy={onCopy} - onDelete={onDelete} - onRename={onRename} - /> - )} - {confirmDelete && ( - { - onDelete?.(asset); - setConfirmDelete(false); - }} - onCancel={() => setConfirmDelete(false)} - /> - )} - - ); -} - function ImageCard({ projectId, asset, diff --git a/packages/studio/src/components/sidebar/AudioRow.tsx b/packages/studio/src/components/sidebar/AudioRow.tsx new file mode 100644 index 000000000..1f5b6e045 --- /dev/null +++ b/packages/studio/src/components/sidebar/AudioRow.tsx @@ -0,0 +1,213 @@ +import { useState, useRef, useEffect, useCallback } from "react"; +import { ContextMenu, DeleteConfirm } from "./AssetContextMenu"; +import { basename, getAudioSubtype } from "./assetHelpers"; +import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop"; + +export function AudioRow({ + projectId, + asset, + used, + meta, + onCopy, + isCopied, + onDelete, + onRename, +}: { + projectId: string; + asset: string; + used: boolean; + meta?: { description?: string; duration?: number }; + onCopy: (path: string) => void; + isCopied: boolean; + onDelete?: (path: string) => void; + onRename?: (oldPath: string, newPath: string) => void; +}) { + const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); + const [confirmDelete, setConfirmDelete] = useState(false); + const [playing, setPlaying] = useState(false); + const [bars, setBars] = useState([]); + const audioRef = useRef(null); + const actxRef = useRef(null); + const analyserRef = useRef(null); + const sourceRef = useRef(null); + const animRef = useRef(0); + const name = basename(asset); + const subtype = getAudioSubtype(asset); + const serveUrl = `/api/projects/${projectId}/preview/${asset}`; + + useEffect(() => { + return () => { + cancelAnimationFrame(animRef.current); + audioRef.current?.pause(); + actxRef.current?.close(); + }; + }, []); + + useEffect(() => { + if (playing) { + const barCount = 24; + const loop = () => { + const analyser = analyserRef.current; + if (!analyser) { + animRef.current = requestAnimationFrame(loop); + return; + } + const data = new Uint8Array(analyser.frequencyBinCount); + analyser.getByteFrequencyData(data); + const step = Math.floor(data.length / barCount); + const next: number[] = []; + for (let i = 0; i < barCount; i++) { + let sum = 0; + for (let j = 0; j < step; j++) sum += data[i * step + j]; + next.push(sum / step / 255); + } + setBars(next); + if (audioRef.current && !audioRef.current.paused) + animRef.current = requestAnimationFrame(loop); + }; + animRef.current = requestAnimationFrame(loop); + } else { + setBars([]); + } + return () => cancelAnimationFrame(animRef.current); + }, [playing]); + + const togglePlay = useCallback(async () => { + if (playing) { + audioRef.current?.pause(); + setPlaying(false); + cancelAnimationFrame(animRef.current); + return; + } + + if (!actxRef.current) { + actxRef.current = new AudioContext(); + analyserRef.current = actxRef.current.createAnalyser(); + analyserRef.current.fftSize = 256; + analyserRef.current.smoothingTimeConstant = 0.7; + } + + if (!audioRef.current) { + const el = new Audio(); + el.onended = () => { + setPlaying(false); + cancelAnimationFrame(animRef.current); + }; + audioRef.current = el; + sourceRef.current = actxRef.current.createMediaElementSource(el); + sourceRef.current.connect(analyserRef.current!); + analyserRef.current!.connect(actxRef.current.destination); + el.src = serveUrl; + } + + if (actxRef.current.state === "suspended") await actxRef.current.resume(); + audioRef.current.currentTime = 0; + await audioRef.current.play(); + setPlaying(true); + }, [serveUrl, playing]); + + return ( + <> +
onCopy(asset)} + onDragStart={(e) => { + e.dataTransfer.effectAllowed = "copy"; + e.dataTransfer.setData(TIMELINE_ASSET_MIME, JSON.stringify({ path: asset })); + e.dataTransfer.setData("text/plain", asset); + }} + onContextMenu={(e) => { + e.preventDefault(); + setContextMenu({ x: e.clientX, y: e.clientY }); + }} + className={`group w-full text-left px-4 py-1.5 flex items-center gap-2.5 transition-all cursor-pointer ${ + playing + ? "bg-panel-accent/[0.06]" + : isCopied + ? "bg-panel-accent/10" + : "hover:bg-panel-surface-hover" + }`} + > + +
+
+ + {name} + + {!playing && ( + + {meta?.duration ? `${meta.duration}s · ` : ""} + {subtype} + + )} + {used && ( + + in use + + )} +
+ {bars.length > 0 && ( +
+ {bars.map((v, i) => ( +
+ ))} +
+ )} +
+
+ + {contextMenu && ( + setContextMenu(null)} + onCopy={onCopy} + onDelete={onDelete} + onRename={onRename} + /> + )} + {confirmDelete && ( + { + onDelete?.(asset); + setConfirmDelete(false); + }} + onCancel={() => setConfirmDelete(false)} + /> + )} + + ); +} From 4c0803b88c20417ed07554818602414bbb8a8abd Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 25 Jun 2026 12:01:04 -0400 Subject: [PATCH 14/33] fix(studio): self-heal stale animationId on 3D property commit A 3D property edit (cube drag / field) picks its target from the panel's selectedGsapAnimations cache. When keyframes were just removed or the script changed underneath, that id is gone server-side and the commit POST 404s ('animation not found'). The raw commitMutation already toasts but rethrows, so the rejection escaped as an uncaught promise. Catch it in commitAnimatedProperties and bump the cache so the panel re-syncs and the next edit self-heals. --- .../src/hooks/useAnimatedPropertyCommit.ts | 48 +++++++++++-------- 1 file changed, 29 insertions(+), 19 deletions(-) diff --git a/packages/studio/src/hooks/useAnimatedPropertyCommit.ts b/packages/studio/src/hooks/useAnimatedPropertyCommit.ts index 9d31ddab3..cff804cea 100644 --- a/packages/studio/src/hooks/useAnimatedPropertyCommit.ts +++ b/packages/studio/src/hooks/useAnimatedPropertyCommit.ts @@ -222,32 +222,42 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) { return; } - // Case 2b: Static hold — merge the props into the `set` (persist as static), - // then auto-keyframe if the element is already animated. - if (anim.method === "set") { - await commitSetProps( + // The picked anim comes from the (possibly stale) panel cache: if keyframes + // were just removed or the script changed underneath us, its id is gone + // server-side and the commit 404s. The raw commit already toasts; we catch + // so the rejection doesn't escape as an uncaught promise, and bump the cache + // so selectedGsapAnimations re-syncs and the user's next edit self-heals. + try { + // Case 2b: Static hold — merge the props into the `set` (persist as static), + // then auto-keyframe if the element is already animated. + if (anim.method === "set") { + await commitSetProps( + selection, + anim, + propEntries, + selector, + selectedGsapAnimations, + gsapCommitMutation, + ); + return; + } + + // Cases 1 & 2: keyframed (or flat → convert) — write ALL props into ONE + // keyframe so a multi-axis cube edit doesn't race into adjacent duplicates. + await commitKeyframeProps( selection, anim, + props, propEntries, + primaryProp, selector, - selectedGsapAnimations, + iframe, gsapCommitMutation, ); - return; + } catch (error) { + log3d("commit-prop", { error: String(error), stale: anim.id, action: "bump-cache" }); + bumpGsapCache(); } - - // Cases 1 & 2: keyframed (or flat → convert) — write ALL props into ONE - // keyframe so a multi-axis cube edit doesn't race into adjacent duplicates. - await commitKeyframeProps( - selection, - anim, - props, - propEntries, - primaryProp, - selector, - iframe, - gsapCommitMutation, - ); }, [selectedGsapAnimations, gsapCommitMutation, addGsapAnimation, previewIframeRef, bumpGsapCache], ); From 5754e3ea39590a6260050cee95ef82bb4038641d Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 25 Jun 2026 12:48:17 -0400 Subject: [PATCH 15/33] fix(studio): batch the 3D reset into one commit (was six flashes) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reset 3D orientation looped six props (rotationX/Y/Z, z, scale, transformPerspective) through the single-property commit, so one click triggered six separate soft-reloads — six preview flashes. Batch them into one onCommitAnimatedProperties call (one keyframe, one reload), matching the cube-drag path. --- .../editor/propertyPanel3dTransform.tsx | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/packages/studio/src/components/editor/propertyPanel3dTransform.tsx b/packages/studio/src/components/editor/propertyPanel3dTransform.tsx index f656d3656..e3e4c51e2 100644 --- a/packages/studio/src/components/editor/propertyPanel3dTransform.tsx +++ b/packages/studio/src/components/editor/propertyPanel3dTransform.tsx @@ -98,15 +98,21 @@ function Cube3dControl({ } }; const recenter = () => { - for (const [prop, identity] of [ - ["rotationX", 0], - ["rotationY", 0], - ["rotationZ", 0], - ["z", 0], - ["scale", 1], - ["transformPerspective", 0], - ] as const) { - void onCommitAnimatedProperty(element, prop, identity); + // ONE commit for the whole reset — six per-axis commits meant six soft-reloads + // (six flashes) for a single click. Batch like commitPose does. + const identity = { + rotationX: 0, + rotationY: 0, + rotationZ: 0, + z: 0, + scale: 1, + transformPerspective: 0, + }; + if (onCommitAnimatedProperties) { + void onCommitAnimatedProperties(element, identity); + } else { + for (const [prop, v] of Object.entries(identity)) + void onCommitAnimatedProperty(element, prop, v); } }; // Immediate element feedback while dragging — set the live transform without a From 0df8e22e82c37ed4be0657b98c00bdfda567d037 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 25 Jun 2026 13:04:17 -0400 Subject: [PATCH 16/33] fix(studio): 3D-edit a static element writes a set, not keyframes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Editing the 3D transform of an element with no keyframes created a keyframed tween (Case 3 made a tl.to() + convert, a flat tween converted to keyframes). A static element should stay static — same as manual drag / resize / rotate, which tl.set() it. Route no-keyframe elements to a set: update an existing one in place, or create a dedicated tl.set carrying all axes in ONE add mutation. The single mutation also avoids the per-axis id race (a flat tween's group-derived id shifts after the first prop, 404-ing the next and polluting an unrelated tween). --- .../src/hooks/useAnimatedPropertyCommit.ts | 108 +++++++++++++----- 1 file changed, 78 insertions(+), 30 deletions(-) diff --git a/packages/studio/src/hooks/useAnimatedPropertyCommit.ts b/packages/studio/src/hooks/useAnimatedPropertyCommit.ts index cff804cea..7aff0e33b 100644 --- a/packages/studio/src/hooks/useAnimatedPropertyCommit.ts +++ b/packages/studio/src/hooks/useAnimatedPropertyCommit.ts @@ -1,10 +1,11 @@ /** * Unified helper for committing any GSAP property value from the design panel. * - * Handles three cases: - * 1. Animation with keyframes → add-keyframe at current percentage - * 2. Flat animation (no keyframes) → convert to keyframes, then add-keyframe - * 3. No animation → create tl.to(), convert to keyframes, then add-keyframe + * Routing depends on whether the element is animated (has keyframes on any tween): + * - Animated → write the value into a keyframe at the current playhead (convert a + * flat tween first if needed). An existing static `set` auto-converts to keyframes. + * - Static (no keyframes anywhere) → persist as a `tl.set`, NEVER keyframes — same + * as manual drag / resize / rotate. Updates an existing set or creates one. */ import { useCallback } from "react"; import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; @@ -129,6 +130,50 @@ async function commitSetProps( await maybeAutoKeyframeSet(selection, setAnim, animations, commit); } +/** + * Static element (no keyframes on ANY of its tweens): persist the 3D props as a + * `tl.set` — NEVER keyframes. Mirrors manual drag / resize / rotate, which `tl.set` + * a static element instead of animating it. Updates an existing `set` in place, or + * creates a dedicated `set` at position 0 when the element has none. + */ +async function commitStaticSet( + selection: DomEditSelection, + propEntries: [string, number | string][], + selector: string | null, + animations: GsapAnimation[], + commit: Commit, +): Promise { + if (!selector) return; + // Only ever update an existing `set` (its id is position-based, so it's stable as + // properties are added) — NEVER a flat `to`/`from`, whose id is group-derived and + // shifts the instant a new-group prop is added, 404-ing the next axis and + // polluting an unrelated tween (e.g. a scale pop). A static element with no set + // gets a dedicated `set` carrying ALL props in ONE `add` (no per-prop id race). + const existingSet = animations.find((a) => a.method === "set" && a.targetSelector === selector); + if (existingSet) { + for (const [property, value] of propEntries) { + const instantPatch = setInstantPatch(selector, property, value); + await commit( + selection, + { type: "update-property", animationId: existingSet.id, property, value }, + { label: `Set ${property}`, softReload: true, ...(instantPatch ? { instantPatch } : {}) }, + ); + } + return; + } + await commit( + selection, + { + type: "add", + targetSelector: selector, + method: "set", + position: 0, + properties: Object.fromEntries(propEntries), + }, + { label: "Set 3D transform", softReload: true }, + ); +} + /** Convert-if-flat, then write ALL props into ONE keyframe at the playhead. */ async function commitKeyframeProps( selection: DomEditSelection, @@ -177,13 +222,7 @@ async function commitKeyframeProps( } export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) { - const { - selectedGsapAnimations, - gsapCommitMutation, - addGsapAnimation, - previewIframeRef, - bumpGsapCache, - } = deps; + const { selectedGsapAnimations, gsapCommitMutation, previewIframeRef, bumpGsapCache } = deps; const commitAnimatedProperties = useCallback( async (selection: DomEditSelection, props: Record): Promise => { @@ -209,18 +248,10 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) { path: commitPathLabel(anim), }); - // Case 3: No animation — create one first - if (!anim) { - addGsapAnimation(selection, "to"); - // The addGsapAnimation triggers a reload. We need to wait for the cache - // to update. Use a small delay then bump cache to re-fetch. - await new Promise((r) => setTimeout(r, 500)); - bumpGsapCache(); - // After creation, we can't proceed in this call — the animation isn't - // in our local state yet. The user's next edit will find it. - // For immediate feedback, trigger a convert-to-keyframes on the new animation. - return; - } + // Whether the element is animated at all. A 3D edit only creates/edits + // keyframes when it IS — a static element (no keyframes on any of its tweens) + // gets a `tl.set`, never new keyframes (matches manual drag / resize / rotate). + const elementHasKeyframes = selectedGsapAnimations.some((a) => !!a.keyframes); // The picked anim comes from the (possibly stale) panel cache: if keyframes // were just removed or the script changed underneath us, its id is gone @@ -228,9 +259,9 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) { // so the rejection doesn't escape as an uncaught promise, and bump the cache // so selectedGsapAnimations re-syncs and the user's next edit self-heals. try { - // Case 2b: Static hold — merge the props into the `set` (persist as static), - // then auto-keyframe if the element is already animated. - if (anim.method === "set") { + // Existing static hold — merge the props into the `set`, then auto-keyframe + // ONLY if the element is already animated (maybeAutoKeyframeSet no-ops if not). + if (anim?.method === "set") { await commitSetProps( selection, anim, @@ -242,8 +273,25 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) { return; } - // Cases 1 & 2: keyframed (or flat → convert) — write ALL props into ONE - // keyframe so a multi-axis cube edit doesn't race into adjacent duplicates. + // Static element — persist as a `tl.set`, never keyframes (incl. the + // no-animation case, which now creates a set instead of a keyframed tween). + if (!elementHasKeyframes) { + await commitStaticSet( + selection, + propEntries, + selector, + selectedGsapAnimations, + gsapCommitMutation, + ); + return; + } + + // Animated element — write ALL props into ONE keyframe so a multi-axis cube + // edit doesn't race into adjacent duplicates. + if (!anim) { + bumpGsapCache(); + return; + } await commitKeyframeProps( selection, anim, @@ -255,11 +303,11 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) { gsapCommitMutation, ); } catch (error) { - log3d("commit-prop", { error: String(error), stale: anim.id, action: "bump-cache" }); + log3d("commit-prop", { error: String(error), stale: anim?.id, action: "bump-cache" }); bumpGsapCache(); } }, - [selectedGsapAnimations, gsapCommitMutation, addGsapAnimation, previewIframeRef, bumpGsapCache], + [selectedGsapAnimations, gsapCommitMutation, previewIframeRef, bumpGsapCache], ); const commitAnimatedProperty = useCallback( From 7a5d4bbf010299a7d28997a3f9435c35dea042ba Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 25 Jun 2026 13:45:12 -0400 Subject: [PATCH 17/33] feat(studio): instant 3D keyframe edits via in-place tween rebuild MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dragging the cube on an animated element soft-reloaded the iframe on every edit (a flash). GSAP compiles object-form keyframes ({ "0%": {...} }) into sub-tweens at creation and ignores later vars.keyframes mutations, so the value can't be patched the way a tl.set can. Instead REBUILD the tween in place: kill it and recreate it on the same parent timeline at the same position with the edited keyframe merged and all other vars preserved, then re-seek — no iframe reload, no flash. Resolution is now channel-aware for keyframe tweens too, so a rotation edit lands on the rotation tween, never a co-located position tween. Declines (→ soft reload) for array-form, motionPath, or dynamic values. --- .../studio/src/hooks/gsapRuntimeKeyframes.ts | 54 ++++++- packages/studio/src/hooks/gsapRuntimePatch.ts | 141 +++++++++++++----- .../src/hooks/useAnimatedPropertyCommit.ts | 16 +- 3 files changed, 170 insertions(+), 41 deletions(-) diff --git a/packages/studio/src/hooks/gsapRuntimeKeyframes.ts b/packages/studio/src/hooks/gsapRuntimeKeyframes.ts index b7e72b285..1a76fc153 100644 --- a/packages/studio/src/hooks/gsapRuntimeKeyframes.ts +++ b/packages/studio/src/hooks/gsapRuntimeKeyframes.ts @@ -12,12 +12,23 @@ import { buildArcPath, type ArcPathConfig } from "@hyperframes/core/gsap-parser- import { parsePercentageKeyframes, toAbsoluteTime } from "./gsapShared"; import { roundTo3 } from "../utils/rounding"; +/** + * A GSAP tween's `vars` object — intentionally open: it mixes channel values + * (numbers), easing (strings), flags (booleans), nested keyframes (objects) and + * callbacks. Named so call sites read as "GSAP config", not an untyped escape hatch. + */ +export type GsapVars = Record; + export interface RuntimeTween { targets?: () => Element[]; - vars?: Record; + vars?: GsapVars; duration?: () => number; startTime?: () => number; invalidate?: () => RuntimeTween; + /** Remove this tween from its parent timeline (GSAP `kill()`). */ + kill?: () => void; + /** The timeline this tween lives in — used to re-insert a rebuilt tween. */ + parent?: RuntimeTimeline; } export interface RuntimeTimeline { @@ -25,6 +36,8 @@ export interface RuntimeTimeline { duration?: () => number; time?: () => number; invalidate?: () => RuntimeTimeline; + /** Add a tween at an absolute position — used to rebuild a keyframe tween in place. */ + to?: (targets: Element[], vars: GsapVars, position?: number) => RuntimeTween; } type Pct = { percentage: number; properties: Record }; @@ -184,6 +197,28 @@ function varsCarryChannel(vars: Record | undefined, channels: s return false; } +/** + * Like `varsCarryChannel` but for a keyframe tween: the channels live inside the + * keyframe steps (`vars.keyframes`), not as own props of `vars`. Handles the object + * form (`{ "0%": {...} }`) and the array form (`[{...}, ...]`). + */ +function keyframeVarsCarryChannel( + vars: Record | undefined, + channels: string[], +): boolean { + const kf = vars?.keyframes; + if (!kf || typeof kf !== "object") return false; + const steps = Array.isArray(kf) ? kf : Object.values(kf); + for (const step of steps) { + if (step && typeof step === "object") { + for (const ch of channels) { + if (Object.prototype.hasOwnProperty.call(step, ch)) return true; + } + } + } + return false; +} + /** * Resolve the live tween targeting `selector` using the SAME all-timelines scan * `readRuntimeKeyframes` uses, so read and write agree on "which tween". With @@ -219,7 +254,12 @@ export function resolveRuntimeTween( ? [compositionId] : Object.keys(timelines).filter((k) => typeof timelines[k]?.getChildren === "function"); - const wantChannels = kind === "set" && channels && channels.length > 0 ? channels : null; + // Channels disambiguate co-located tweens for BOTH kinds: a `set` carries them as + // own vars props, a keyframe tween carries them inside its keyframe steps. An + // element can have a rotation keyframe tween AND a position keyframe tween; a + // rotation edit must land on the former. The reader passes no channels, so its + // playhead-containment path below is unchanged. + const wantChannels = channels && channels.length > 0 ? channels : null; let first: ResolvedRuntimeTween | null = null; let channelMatch: ResolvedRuntimeTween | null = null; @@ -233,11 +273,15 @@ export function resolveRuntimeTween( const isSet = !(dur > 0); if (kind === "set" ? !isSet : isSet) continue; if (wantChannels) { - if (varsCarryChannel(tween.vars, wantChannels)) { + const carries = + kind === "set" + ? varsCarryChannel(tween.vars, wantChannels) + : keyframeVarsCarryChannel(tween.vars, wantChannels); + if (carries) { if (channelMatch === null) channelMatch = { tween, timeline }; } else if (first === null) { - // A set carrying only disjoint channels: remember as last-resort - // fallback, but never prefer it over a channel-matching set. + // A tween carrying only disjoint channels: remember as last-resort + // fallback, but never prefer it over a channel-matching one. first = { tween, timeline }; } continue; diff --git a/packages/studio/src/hooks/gsapRuntimePatch.ts b/packages/studio/src/hooks/gsapRuntimePatch.ts index e00cc8127..0c954c554 100644 --- a/packages/studio/src/hooks/gsapRuntimePatch.ts +++ b/packages/studio/src/hooks/gsapRuntimePatch.ts @@ -13,7 +13,11 @@ * "Which tween" is resolved by the same all-timelines scan `readRuntimeKeyframes` * uses (`resolveRuntimeTween`), so read and write agree on the target. */ -import { resolveRuntimeTween, type RuntimeTween } from "./gsapRuntimeKeyframes"; +import { + resolveRuntimeTween, + type RuntimeTween, + type RuntimeTimeline, +} from "./gsapRuntimeKeyframes"; /** Value-only channels a `tl.set(...)` patch may touch. */ export interface SetPatchProps { @@ -36,7 +40,13 @@ export type KeyframeStep = Record; export type RuntimeTweenChange = | { kind: "set"; props: SetPatchProps } - | { kind: "keyframes"; keyframes: KeyframeStep[] }; + | { kind: "keyframes"; keyframes: KeyframeStep[] } + // Edit ONE step (at `pct`) of an object-form keyframe tween — the form the studio + // writes (`keyframes: { "0%": {...} }`). GSAP pre-compiles those into sub-tweens + // and won't re-read `vars.keyframes` on `invalidate()`, so this REBUILDS the tween + // (kill + recreate at the same position) instead of mutating it. Lets a design-panel + // keyframe edit show instantly rather than soft-reloading the iframe (a flash). + | { kind: "keyframe-rebuild"; pct: number; props: KeyframeStep }; const SET_CHANNELS: Array = [ "x", @@ -110,10 +120,91 @@ function patchKeyframes(tween: RuntimeTween, keyframes: KeyframeStep[]): boolean return true; } +/** The object-form keyframes map with `props` merged into the step at `pct`. */ +function mergeKeyframeStep( + map: Record>, + pct: number, + props: KeyframeStep, +): Record> { + const next: Record> = {}; + for (const [k, step] of Object.entries(map)) next[k] = { ...step }; + // Match the existing percentage key numerically ("50%" ≡ pct 50), else add one. + let key: string | null = null; + for (const k of Object.keys(next)) { + const n = parseFloat(k); + if (Number.isFinite(n) && Math.abs(n - pct) < 0.05) { + key = k; + break; + } + } + if (key === null) key = `${pct}%`; + next[key] = { ...(next[key] ?? {}), ...props }; + return next; +} + +/** + * Rebuild an object-form keyframe tween with `props` merged into the step at `pct`, + * in place: kill the old tween and recreate it on the SAME parent timeline at the + * SAME position, with all other vars (duration, ease, repeat, …) preserved. This + * is the only way to reflect an object-form keyframe edit live — GSAP compiles + * those keyframes into sub-tweens at creation and ignores later `vars.keyframes` + * mutations. Declines (→ caller soft-reloads) for array-form, motionPath arcs, + * non-finite/dynamic values, or a tween whose parent/targets can't be resolved. + */ +function rebuildKeyframeTween(tween: RuntimeTween, pct: number, props: KeyframeStep): boolean { + const vars = tween.vars; + if (!vars || "motionPath" in vars) return false; + const kf = vars.keyframes; + if (!kf || typeof kf !== "object" || Array.isArray(kf)) return false; + for (const v of Object.values(props)) { + if (typeof v !== "number" || !Number.isFinite(v)) return false; + } + const parent = tween.parent; + const targets = tween.targets?.(); + if (!parent?.to || !targets || targets.length === 0) return false; + if (typeof tween.startTime !== "function" || typeof tween.kill !== "function") return false; + + const next = mergeKeyframeStep(kf as Record>, pct, props); + const newVars = { ...vars, keyframes: next }; + const position = tween.startTime(); + tween.kill(); + parent.to(targets, newVars, position); + return true; +} + +/** The channels a change writes, for the resolver to disambiguate co-located tweens. */ +function changeChannels(change: RuntimeTweenChange): string[] | undefined { + if (change.kind === "set") { + return Object.keys(change.props).filter( + (k) => change.props[k as keyof SetPatchProps] !== undefined, + ); + } + if (change.kind === "keyframe-rebuild") return Object.keys(change.props); + return undefined; +} + +/** Re-render the timeline at the current playhead after an in-place edit. */ +function seekToCurrent(iframe: HTMLIFrameElement, timeline: RuntimeTimeline): void { + const player = playerOf(iframe); + const currentTime = + typeof player?.getTime === "function" + ? player.getTime() + : typeof timeline.time === "function" + ? timeline.time() + : 0; + player?.seek?.(Number.isFinite(currentTime) ? currentTime : 0); +} + +/** Apply `change` to the resolved tween. `true` if applied, `false` to soft-reload. */ +function applyChange(tween: RuntimeTween, change: RuntimeTweenChange): boolean { + if (change.kind === "set") return patchSet(tween, change.props); + if (change.kind === "keyframes") return patchKeyframes(tween, change.keyframes); + return rebuildKeyframeTween(tween, change.pct, change.props); +} + /** - * Update one tween's values in `window.__timelines` in place + re-seek to the - * current playhead. Returns `true` on a confident patch, `false` otherwise - * (caller falls back to a soft reload). + * Edit one tween in `window.__timelines` in place + re-seek to the current playhead. + * Returns `true` on a confident patch, `false` otherwise (caller soft-reloads). */ export function patchRuntimeTweenInPlace( iframe: HTMLIFrameElement | null, @@ -123,45 +214,25 @@ export function patchRuntimeTweenInPlace( ): boolean { if (!iframe) return false; try { - // For a `set` patch, hand the resolver the channels actually being written so - // it picks the set whose vars carry them — an element can have separate - // {x,y} and {rotation} sets, and a position patch must not corrupt the - // rotation set (channel-blind resolution would return the first match). - const channels = - change.kind === "set" - ? Object.keys(change.props).filter( - (k) => change.props[k as keyof SetPatchProps] !== undefined, - ) - : undefined; const resolved = resolveRuntimeTween( iframe, selector, change.kind === "set" ? "set" : "keyframe", compositionId, - channels, + changeChannels(change), ); if (!resolved) return false; const { tween, timeline } = resolved; - const applied = - change.kind === "set" - ? patchSet(tween, change.props) - : patchKeyframes(tween, change.keyframes); - if (!applied) return false; - - // Recompute the tween (and its timeline) from the new vars, then re-render at - // the current playhead. `invalidate()` makes GSAP re-read vars on the next render. - tween.invalidate?.(); - timeline.invalidate?.(); - - const player = playerOf(iframe); - const currentTime = - typeof player?.getTime === "function" - ? player.getTime() - : typeof timeline.time === "function" - ? timeline.time() - : 0; - player?.seek?.(Number.isFinite(currentTime) ? currentTime : 0); + if (!applyChange(tween, change)) return false; + + // A rebuild already recreated the tween; set/keyframes mutate vars in place, so + // invalidate to make GSAP re-read them on the next render. Either way, re-seek. + if (change.kind !== "keyframe-rebuild") { + tween.invalidate?.(); + timeline.invalidate?.(); + } + seekToCurrent(iframe, timeline); return true; } catch { return false; diff --git a/packages/studio/src/hooks/useAnimatedPropertyCommit.ts b/packages/studio/src/hooks/useAnimatedPropertyCommit.ts index 7aff0e33b..86ed96fb2 100644 --- a/packages/studio/src/hooks/useAnimatedPropertyCommit.ts +++ b/packages/studio/src/hooks/useAnimatedPropertyCommit.ts @@ -206,6 +206,16 @@ async function commitKeyframeProps( } const existingKf = anim.keyframes?.keyframes.some((kf) => Math.abs(kf.percentage - pct) < 0.05); + // Rebuild the live keyframe tween in place so the edit shows instantly (no flash); + // rebuildKeyframeTween declines → soft reload if the tween can't be safely rebuilt. + const numericProps: Record = {}; + for (const [k, v] of Object.entries(properties)) { + if (typeof v === "number") numericProps[k] = v; + } + const instantPatch = + selector && Object.keys(numericProps).length > 0 + ? { selector, change: { kind: "keyframe-rebuild" as const, pct, props: numericProps } } + : undefined; await commit( selection, existingKf @@ -217,7 +227,11 @@ async function commitKeyframeProps( properties, backfillDefaults, }, - { label: `Edit ${primaryProp} (keyframe ${pct}%)`, softReload: true }, + { + label: `Edit ${primaryProp} (keyframe ${pct}%)`, + softReload: true, + ...(instantPatch ? { instantPatch } : {}), + }, ); } From 84f61187183a308b880e09a549e6b27875f643a6 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 25 Jun 2026 15:37:57 -0400 Subject: [PATCH 18/33] feat(studio): static 3D transform persists as off-timeline gsap.set (no 0% keyframe) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adjusting a 3D transform on an element with no keyframes created a tl.set(...,0), which the timeline renders as a 0% keyframe diamond — even though it's a static hold, not animated. Persist a newly-created static 3D hold as a base gsap.set(...) instead: it runs immediately, sits OFF the timeline, and shows no keyframe marker (matching the manual-drag UX). - Model: GsapAnimation.global marks a base gsap.set vs an on-timeline tl.set. - Parser (recast + acorn): parse a STRING-LITERAL gsap.set("#sel", {...}) as an editable global set so it round-trips and re-edits in place; variable-target gsap.set(el, ...) holds stay opaque surrounding source (unchanged). - Serializer + writers: emit gsap.set(sel, props) (no timeline var, no position) when global; in-place updates keep it a gsap.set. - add mutation gains global; commitStaticSet sends it when creating a holder. --- packages/core/src/parsers/gsapParser.test.ts | 54 +++++++++++++++++++ packages/core/src/parsers/gsapParser.ts | 29 ++++++++-- packages/core/src/parsers/gsapParserAcorn.ts | 18 ++++++- packages/core/src/parsers/gsapSerialize.ts | 10 +++- packages/core/src/parsers/gsapWriterAcorn.ts | 4 ++ packages/core/src/studio-api/routes/files.ts | 4 ++ .../src/hooks/useAnimatedPropertyCommit.ts | 4 ++ 7 files changed, 118 insertions(+), 5 deletions(-) diff --git a/packages/core/src/parsers/gsapParser.test.ts b/packages/core/src/parsers/gsapParser.test.ts index e0c31704a..7d7c66696 100644 --- a/packages/core/src/parsers/gsapParser.test.ts +++ b/packages/core/src/parsers/gsapParser.test.ts @@ -2832,3 +2832,57 @@ tl.to("#el", { y: 50, duration: 1 }, "+=0.5");`; expect(parsed.animations[1].position).toBe("+=0.5"); }); }); + +describe("base gsap.set (off-timeline global hold)", () => { + const SCRIPT = ` + const tl = gsap.timeline({ paused: true }); + gsap.set("#box", { rotationX: 17, rotationY: 93 }); + tl.to("#box", { x: 260, duration: 1 }, 0.3); + window.__timelines = { main: tl }; + `; + + it("parses a string-literal gsap.set as a global set animation", () => { + const anims = parseGsapScript(SCRIPT).animations.filter((a) => a.targetSelector === "#box"); + const set = anims.find((a) => a.method === "set"); + expect(set?.global).toBe(true); + expect(set?.properties).toEqual({ rotationX: 17, rotationY: 93 }); + expect(anims.find((a) => a.method === "to")?.global).toBeUndefined(); + }); + + it("creates a base gsap.set (not tl.set) when global is set", () => { + const base = `const tl = gsap.timeline({ paused: true });\ntl.to("#box", { x: 1, duration: 1 }, 0);\nwindow.__timelines = { main: tl };`; + const { script } = addAnimationToScript(base, { + targetSelector: "#box", + method: "set", + position: 0, + properties: { rotationX: 30 }, + global: true, + }); + expect(script).toContain('gsap.set("#box"'); + expect(script).not.toContain('tl.set("#box"'); + }); + + it("updates a global set in place, keeping it gsap.set", () => { + const set = parseGsapScript(SCRIPT).animations.find( + (a) => a.targetSelector === "#box" && a.method === "set", + )!; + const updated = updateAnimationInScript(SCRIPT, set.id, { + properties: { rotationX: 99, rotationY: 93 }, + }); + expect(updated).toContain('gsap.set("#box"'); + expect(updated).toContain("99"); + expect(updated).not.toContain('tl.set("#box"'); + }); + + it("leaves a VARIABLE-target gsap.set as surrounding source (not parsed)", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + const el = document.querySelector("#box"); + gsap.set(el, { rotationX: 5 }); + tl.to("#box", { x: 10, duration: 1 }, 0); + window.__timelines = { main: tl }; + `; + const sets = parseGsapScript(script).animations.filter((a) => a.method === "set"); + expect(sets).toHaveLength(0); + }); +}); diff --git a/packages/core/src/parsers/gsapParser.ts b/packages/core/src/parsers/gsapParser.ts index 2f007f4b1..c20002bd4 100644 --- a/packages/core/src/parsers/gsapParser.ts +++ b/packages/core/src/parsers/gsapParser.ts @@ -440,6 +440,8 @@ interface TweenCallInfo { varsArg: AstNode; fromArg?: AstNode; positionArg?: AstNode; + /** True for a base `gsap.set(...)` (off-timeline) rather than `tl.set(...)`. */ + global?: boolean; } /** @@ -465,10 +467,24 @@ function findAllTweenCalls( visitCallExpression(path: AstPath) { const node = path.node; const callee = node.callee; + // A base `gsap.set("#sel", props)` is an off-timeline static hold (no position, + // no keyframe marker). Treat it as an editable `set` animation so a static + // value (e.g. a 3D transform) round-trips and re-edits in place. Restricted to + // a STRING-LITERAL selector: variable-target `gsap.set(el, ...)` holds stay + // opaque surrounding source (editing them by selector would be ambiguous). + const gsapSetArg = node.arguments?.[0]; + const isGlobalSet = + callee?.type === "MemberExpression" && + callee.object?.type === "Identifier" && + callee.object.name === "gsap" && + callee.property?.type === "Identifier" && + callee.property.name === "set" && + (gsapSetArg?.type === "StringLiteral" || + (gsapSetArg?.type === "Literal" && typeof gsapSetArg.value === "string")); if ( callee?.type === "MemberExpression" && callee.property?.type === "Identifier" && - isTimelineRootedCall(node, timelineVar) + (isTimelineRootedCall(node, timelineVar) || isGlobalSet) ) { const method = callee.property.name; if (!GSAP_METHODS.has(method)) { @@ -501,6 +517,7 @@ function findAllTweenCalls( selector: selectorValue, varsArg: args[1], positionArg: args[2], + ...(isGlobalSet ? { global: true } : {}), }); } } @@ -968,6 +985,7 @@ function tweenCallToAnimation( group = classifyTweenPropertyGroup(kfProps); } if (group) anim.propertyGroup = group; + if (call.global) anim.global = true; if (Object.keys(extras).length > 0) anim.extras = extras; if (keyframesData) anim.keyframes = keyframesData; if (motionPathResult) anim.arcPath = motionPathResult.arcPath; @@ -1306,8 +1324,9 @@ function buildTweenStatementCode(timelineVar: string, anim: Omit `${safeKey(k)}: ${valueToCode(v)}`); // immediateRender forces GSAP to apply the set when added to the timeline, // not on the first seek — without it, tl.set at position 0 on a paused - // timeline is invisible until the playhead moves past 0. - if (anim.method === "set") entries.push("immediateRender: true"); + // timeline is invisible until the playhead moves past 0. A base `gsap.set` + // already runs immediately, so it doesn't need (or get) the flag. + if (anim.method === "set" && !anim.global) entries.push("immediateRender: true"); if (anim.extras) { for (const [k, v] of Object.entries(anim.extras)) { entries.push(`${safeKey(k)}: ${valueToCode(v as number | string)}`); @@ -1324,6 +1343,10 @@ function buildTweenStatementCode(timelineVar: string, anim: Omit 0) anim.extras = extras; if (keyframesData) anim.keyframes = keyframesData; if (motionPathResult) anim.arcPath = motionPathResult.arcPath; diff --git a/packages/core/src/parsers/gsapSerialize.ts b/packages/core/src/parsers/gsapSerialize.ts index 8871f5b5c..c5832c715 100644 --- a/packages/core/src/parsers/gsapSerialize.ts +++ b/packages/core/src/parsers/gsapSerialize.ts @@ -73,6 +73,11 @@ export interface GsapAnimation { /** Which property group this tween belongs to (position, scale, size, rotation, visual, other). * Undefined for legacy mixed tweens that bundle multiple groups. */ propertyGroup?: PropertyGroupName; + /** True for a base `gsap.set(...)` (a static hold that runs immediately, OFF the + * timeline) rather than `tl.set(...)`. Carries no timeline position and shows no + * keyframe marker — used to persist a static value (e.g. a 3D transform) without + * introducing a 0% keyframe. */ + global?: boolean; /** How this tween was constructed in source. Absent ⇒ literal. */ provenance?: GsapProvenance; } @@ -202,7 +207,10 @@ export function serializeGsapAnimations( const posStr = typeof anim.position === "string" ? `"${anim.position}"` : anim.position; switch (anim.method) { case "set": - return ` ${timelineVar}.set(${selector}, ${propsStr}, ${posStr});`; + // A global set is a base `gsap.set` — off the timeline, no position arg. + return anim.global + ? ` gsap.set(${selector}, ${propsStr});` + : ` ${timelineVar}.set(${selector}, ${propsStr}, ${posStr});`; case "to": return ` ${timelineVar}.to(${selector}, ${propsStr}, ${posStr});`; case "from": diff --git a/packages/core/src/parsers/gsapWriterAcorn.ts b/packages/core/src/parsers/gsapWriterAcorn.ts index 2d09d62cb..e514cd579 100644 --- a/packages/core/src/parsers/gsapWriterAcorn.ts +++ b/packages/core/src/parsers/gsapWriterAcorn.ts @@ -72,6 +72,10 @@ function buildTweenStatementCode(timelineVar: string, anim: Omit; fromProperties?: Record; + /** Emit a base `gsap.set` (off-timeline, no keyframe marker) instead of `tl.set`. */ + global?: boolean; } | { type: "delete"; animationId: string; stripStudioEdits?: boolean } | { @@ -723,6 +725,7 @@ function executeGsapMutationAcorn( ease: body.ease, properties: body.properties, fromProperties: body.fromProperties, + ...(body.global ? { global: true } : {}), }); return result.script; } @@ -1017,6 +1020,7 @@ async function executeGsapMutationRecast( ease: body.ease, properties: body.properties, fromProperties: body.fromProperties, + ...(body.global ? { global: true } : {}), }); return result.script; } diff --git a/packages/studio/src/hooks/useAnimatedPropertyCommit.ts b/packages/studio/src/hooks/useAnimatedPropertyCommit.ts index 86ed96fb2..4332a150d 100644 --- a/packages/studio/src/hooks/useAnimatedPropertyCommit.ts +++ b/packages/studio/src/hooks/useAnimatedPropertyCommit.ts @@ -169,6 +169,10 @@ async function commitStaticSet( method: "set", position: 0, properties: Object.fromEntries(propEntries), + // Base `gsap.set` (off-timeline) — a static hold with no 0% keyframe marker, + // so adjusting a 3D transform on a non-keyframed element doesn't drop a + // keyframe on the timeline (matches the manual-drag UX). + global: true, }, { label: "Set 3D transform", softReload: true }, ); From 35a56850548e68de3ee1a17a17e06155033b4a23 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 25 Jun 2026 16:04:28 -0400 Subject: [PATCH 19/33] fix(studio): static manual drag persists as off-timeline gsap.set, instant (no flash/diamond) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After keyframes are removed, manually dragging a now-static element wrote a tl.set(...,0) — an on-timeline hold that shows a 0% keyframe diamond and soft-reloaded on the first nudge (a flash/teleport between the overlay and the committed position). Make the static position/rotation drag persist as a base gsap.set (off-timeline, no marker), like the 3D path. A gsap.set has no runtime tween to patch, so add a 'global-set' instant-patch that applies the value straight to the element (gsap.set(el, props)) — the element is static on these channels, so it reflects instantly with no soft reload. Existing tl.set holds keep the tween 'set' patch; only global sets use global-set. Create now carries the instant patch too, so the first nudge is flash-free. --- .../studio/src/hooks/gsapDragCommit.test.ts | 14 ++++-- packages/studio/src/hooks/gsapDragCommit.ts | 37 +++++++++++---- packages/studio/src/hooks/gsapRuntimePatch.ts | 46 +++++++++++++++++-- .../src/hooks/useAnimatedPropertyCommit.ts | 32 +++++++++---- 4 files changed, 104 insertions(+), 25 deletions(-) diff --git a/packages/studio/src/hooks/gsapDragCommit.test.ts b/packages/studio/src/hooks/gsapDragCommit.test.ts index 06461d287..e25d51071 100644 --- a/packages/studio/src/hooks/gsapDragCommit.test.ts +++ b/packages/studio/src/hooks/gsapDragCommit.test.ts @@ -332,7 +332,7 @@ describe("commitStaticGsapPosition — instantPatch (value-only set)", () => { expect(yPatch.change.props[xMutation.property]).toBe(xMutation.value); }); - it("does NOT attach instantPatch when ADDING a new set (structural — new tween)", async () => { + it("ADDS a global gsap.set with a global-set instantPatch (off-timeline, no flash)", async () => { const { commits, callbacks } = optionRecordingCallbacks(); await commitStaticGsapPosition( @@ -340,13 +340,15 @@ describe("commitStaticGsapPosition — instantPatch (value-only set)", () => { { x: -50, y: 30 }, { x: 0, y: 0 }, "#puck-a", - null, // no existing set → `add` a new tween + null, // no existing set → `add` a new base gsap.set callbacks, ); expect(commits).toHaveLength(1); expect(commits[0].mutation.type).toBe("add"); - expect(commits[0].options.instantPatch).toBeUndefined(); + expect((commits[0].mutation as { global?: boolean }).global).toBe(true); + const patch = commits[0].options.instantPatch as { change: { kind: string } } | undefined; + expect(patch?.change.kind).toBe("global-set"); }); }); @@ -372,14 +374,16 @@ describe("commitStaticGsapRotation — instantPatch (value-only set)", () => { expect(patch.change.props[m.property]).toBe(m.value); }); - it("does NOT attach instantPatch when ADDING a new rotation set (structural)", async () => { + it("ADDS a global gsap.set with a global-set instantPatch (off-timeline, no flash)", async () => { const { commits, callbacks } = optionRecordingCallbacks(); await commitStaticGsapRotation(selection(), 42, "#puck-a", null, callbacks); expect(commits).toHaveLength(1); expect(commits[0].mutation.type).toBe("add"); - expect(commits[0].options.instantPatch).toBeUndefined(); + expect((commits[0].mutation as { global?: boolean }).global).toBe(true); + const patch = commits[0].options.instantPatch as { change: { kind: string } } | undefined; + expect(patch?.change.kind).toBe("global-set"); }); }); diff --git a/packages/studio/src/hooks/gsapDragCommit.ts b/packages/studio/src/hooks/gsapDragCommit.ts index c2d452daa..43260ae5e 100644 --- a/packages/studio/src/hooks/gsapDragCommit.ts +++ b/packages/studio/src/hooks/gsapDragCommit.ts @@ -120,18 +120,22 @@ interface UpdatePropertyMutation { function setPatchFromUpdateProperties( selector: string, mutations: UpdatePropertyMutation[], + global = false, ): { selector: string; change: RuntimeTweenChange } { const props: SetPatchProps = {}; for (const m of mutations) props[m.property as keyof SetPatchProps] = m.value; - return { selector, change: { kind: "set", props } }; + // An off-timeline `gsap.set` has no runtime tween to patch — apply it to the + // element directly. An on-timeline `tl.set` mutates its tween (so a re-seek keeps it). + return { selector, change: { kind: global ? "global-set" : "set", props } }; } /** Single-mutation convenience over {@link setPatchFromUpdateProperties}. */ function setPatchFromUpdateProperty( selector: string, mutation: UpdatePropertyMutation, + global = false, ): { selector: string; change: RuntimeTweenChange } { - return setPatchFromUpdateProperties(selector, [mutation]); + return setPatchFromUpdateProperties(selector, [mutation], global); } /** @@ -194,22 +198,25 @@ export async function commitStaticGsapPosition( // preview still reflects what DID persist. The x commit carries skipReload // (no reload), so its instantPatch gives instant feedback without a reload; // the y commit triggers the soft reload (skipped when the patch applies). + const global = !!existingSet.global; await callbacks.commitMutation(selection, xMutation, { label: "Move layer", skipReload: true, coalesceKey, - instantPatch: setPatchFromUpdateProperty(selector, xMutation), + instantPatch: setPatchFromUpdateProperty(selector, xMutation, global), }); await callbacks.commitMutation(selection, yMutation, { label: "Move layer", softReload: true, coalesceKey, // Final commit of the coalesced x/y pair: carry both channels so the - // runtime `tl.set` lands the complete {x,y} pose in place. - instantPatch: setPatchFromUpdateProperties(selector, [xMutation, yMutation]), + // runtime set lands the complete {x,y} pose in place. + instantPatch: setPatchFromUpdateProperties(selector, [xMutation, yMutation], global), }); return; } + // New static hold → a base `gsap.set` (off-timeline, no 0% keyframe marker), with + // an instant patch so the first nudge shows immediately (no soft-reload flash). await callbacks.commitMutation( selection, { @@ -218,8 +225,13 @@ export async function commitStaticGsapPosition( method: "set", position: 0, properties: { x: newX, y: newY }, + global: true, + }, + { + label: "Move layer", + softReload: true, + instantPatch: { selector, change: { kind: "global-set", props: { x: newX, y: newY } } }, }, - { label: "Move layer", softReload: true }, ); } @@ -264,11 +276,13 @@ export async function commitStaticGsapRotation( await callbacks.commitMutation(selection, rotationMutation, { label: "Rotate layer", softReload: true, - // Value-only rotation set: patch the runtime `tl.set` rotation in place. - instantPatch: setPatchFromUpdateProperty(selector, rotationMutation), + // Value-only rotation set — patch the runtime in place (off-timeline gsap.set + // applies to the element directly; on-timeline tl.set patches its tween). + instantPatch: setPatchFromUpdateProperty(selector, rotationMutation, !!existingSet.global), }); return; } + // New static hold → off-timeline `gsap.set` (no 0% keyframe marker) + instant patch. await callbacks.commitMutation( selection, { @@ -277,8 +291,13 @@ export async function commitStaticGsapRotation( method: "set", position: 0, properties: { rotation: newRotation }, + global: true, + }, + { + label: "Rotate layer", + softReload: true, + instantPatch: { selector, change: { kind: "global-set", props: { rotation: newRotation } } }, }, - { label: "Rotate layer", softReload: true }, ); } diff --git a/packages/studio/src/hooks/gsapRuntimePatch.ts b/packages/studio/src/hooks/gsapRuntimePatch.ts index 0c954c554..ac7ac0c8f 100644 --- a/packages/studio/src/hooks/gsapRuntimePatch.ts +++ b/packages/studio/src/hooks/gsapRuntimePatch.ts @@ -46,7 +46,12 @@ export type RuntimeTweenChange = // and won't re-read `vars.keyframes` on `invalidate()`, so this REBUILDS the tween // (kill + recreate at the same position) instead of mutating it. Lets a design-panel // keyframe edit show instantly rather than soft-reloading the iframe (a flash). - | { kind: "keyframe-rebuild"; pct: number; props: KeyframeStep }; + | { kind: "keyframe-rebuild"; pct: number; props: KeyframeStep } + // Apply a base `gsap.set` value to the element directly (`gsap.set(el, props)`). + // A base set lives OFF the timeline, so there's no runtime tween to patch — but + // the element is static on these channels, so setting them immediately reflects + // the edit with no soft reload (no flash) and leaves no keyframe marker. + | { kind: "global-set"; props: SetPatchProps }; const SET_CHANNELS: Array = [ "x", @@ -65,8 +70,38 @@ const SET_CHANNELS: Array = [ type IframeWindow = Window & { __player?: { getTime?: () => number; seek?: (t: number) => void }; + gsap?: { set?: (target: Element, vars: Record) => void }; }; +/** + * Apply a base `gsap.set` value to the element in the live runtime. Returns `true` + * if applied. Used for off-timeline static holds (position / 3D transform) — there's + * no tween to patch, so we set the channels directly. Safe because the element is + * static on these channels (the caller only uses this for non-animated values). + */ +function applyGlobalSet( + iframe: HTMLIFrameElement, + selector: string, + props: SetPatchProps, +): boolean { + try { + const win = iframe.contentWindow as IframeWindow | null; + const gsapLib = win?.gsap; + const el = iframe.contentDocument?.querySelector(selector) ?? null; + if (!gsapLib?.set || !el) return false; + const numeric: Record = {}; + for (const [k, v] of Object.entries(props)) { + if (typeof v !== "number" || !Number.isFinite(v)) return false; + numeric[k] = v; + } + if (Object.keys(numeric).length === 0) return false; + gsapLib.set(el, numeric); + return true; + } catch { + return false; + } +} + function playerOf(iframe: HTMLIFrameElement): IframeWindow["__player"] | null { try { return (iframe.contentWindow as IframeWindow | null)?.__player ?? null; @@ -195,11 +230,13 @@ function seekToCurrent(iframe: HTMLIFrameElement, timeline: RuntimeTimeline): vo player?.seek?.(Number.isFinite(currentTime) ? currentTime : 0); } -/** Apply `change` to the resolved tween. `true` if applied, `false` to soft-reload. */ +/** Apply `change` to the resolved tween. `true` if applied, `false` to soft-reload. + * `global-set` is handled before this (no tween) and never reaches here. */ function applyChange(tween: RuntimeTween, change: RuntimeTweenChange): boolean { if (change.kind === "set") return patchSet(tween, change.props); if (change.kind === "keyframes") return patchKeyframes(tween, change.keyframes); - return rebuildKeyframeTween(tween, change.pct, change.props); + if (change.kind === "keyframe-rebuild") return rebuildKeyframeTween(tween, change.pct, change.props); + return false; } /** @@ -213,6 +250,9 @@ export function patchRuntimeTweenInPlace( compositionId?: string, ): boolean { if (!iframe) return false; + // A base `gsap.set` has no timeline tween to resolve — apply the value straight + // to the element so the edit shows instantly (no soft reload, no flash). + if (change.kind === "global-set") return applyGlobalSet(iframe, selector, change.props); try { const resolved = resolveRuntimeTween( iframe, diff --git a/packages/studio/src/hooks/useAnimatedPropertyCommit.ts b/packages/studio/src/hooks/useAnimatedPropertyCommit.ts index 4332a150d..edfe4aa69 100644 --- a/packages/studio/src/hooks/useAnimatedPropertyCommit.ts +++ b/packages/studio/src/hooks/useAnimatedPropertyCommit.ts @@ -72,14 +72,19 @@ function commitPathLabel(anim: GsapAnimation | undefined): string { return anim.keyframes ? "keyframe" : "convert+keyframe"; } -/** The in-place `set` patch for a value-only commit (no soft reload), or none. */ +/** The in-place patch for a value-only commit (no soft reload), or none. A global + * (`gsap.set`) hold has no runtime tween, so it applies straight to the element. */ function setInstantPatch( selector: string | null, property: string, value: number | string, -): { selector: string; change: { kind: "set"; props: SetPatchProps } } | undefined { + global = false, +): { selector: string; change: { kind: "set" | "global-set"; props: SetPatchProps } } | undefined { if (!selector || typeof value !== "number") return undefined; - return { selector, change: { kind: "set", props: { [property]: value } as SetPatchProps } }; + return { + selector, + change: { kind: global ? "global-set" : "set", props: { [property]: value } as SetPatchProps }, + }; } /** @@ -120,7 +125,7 @@ async function commitSetProps( commit: Commit, ): Promise { for (const [property, value] of propEntries) { - const instantPatch = setInstantPatch(selector, property, value); + const instantPatch = setInstantPatch(selector, property, value, !!setAnim.global); await commit( selection, { type: "update-property", animationId: setAnim.id, property, value }, @@ -161,6 +166,14 @@ async function commitStaticSet( } return; } + // Base `gsap.set` (off-timeline) — a static hold with no 0% keyframe marker, so + // adjusting a 3D transform on a non-keyframed element doesn't drop a keyframe on + // the timeline (matches the manual-drag UX). The global-set instant patch applies + // it straight to the element so the first edit shows with no soft-reload flash. + const numericProps: SetPatchProps = {}; + for (const [k, v] of propEntries) { + if (typeof v === "number") numericProps[k as keyof SetPatchProps] = v; + } await commit( selection, { @@ -169,12 +182,15 @@ async function commitStaticSet( method: "set", position: 0, properties: Object.fromEntries(propEntries), - // Base `gsap.set` (off-timeline) — a static hold with no 0% keyframe marker, - // so adjusting a 3D transform on a non-keyframed element doesn't drop a - // keyframe on the timeline (matches the manual-drag UX). global: true, }, - { label: "Set 3D transform", softReload: true }, + { + label: "Set 3D transform", + softReload: true, + ...(Object.keys(numericProps).length > 0 + ? { instantPatch: { selector, change: { kind: "global-set" as const, props: numericProps } } } + : {}), + }, ); } From 3e8f938382c0c475b522ae2beb1692d2b5e8c367 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 25 Jun 2026 16:27:49 -0400 Subject: [PATCH 20/33] fix(studio): a base gsap.set shows no keyframe diamond (timeline + panel) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A base gsap.set is parsed as an editable set (for idempotent re-edits), but synthesizeFlatTweenKeyframes turned it into a synthetic 0% keyframe, so the timeline track and the panel field showed a phantom keyframe diamond for a static, non-animated value. Return null for a global set so it contributes no keyframes — it's an off-timeline static hold, not a keyframe. --- packages/studio/src/hooks/useGsapTweenCache.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/studio/src/hooks/useGsapTweenCache.ts b/packages/studio/src/hooks/useGsapTweenCache.ts index bec9757ae..24b537837 100644 --- a/packages/studio/src/hooks/useGsapTweenCache.ts +++ b/packages/studio/src/hooks/useGsapTweenCache.ts @@ -27,6 +27,10 @@ function deduplicateKeyframes(keyframes: GsapPercentageKeyframe[]): GsapPercenta // fallow-ignore-next-line complexity function synthesizeFlatTweenKeyframes(anim: GsapAnimation): GsapKeyframesData | null { if (anim.method === "set") { + // A base `gsap.set` is an off-timeline static hold (no position) — it must NOT + // synthesize a 0% keyframe, or the timeline + panel would show a phantom + // keyframe diamond for a value that isn't animated. + if (anim.global) return null; return { format: "percentage", keyframes: [{ percentage: 0, properties: { ...anim.properties } }], From 4c63342198b6cefa477267ead887c232dd2fc7f3 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 25 Jun 2026 16:38:39 -0400 Subject: [PATCH 21/33] fix(studio): a static set never shows a keyframe diamond (timeline + panel) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A set (gsap.set OR tl.set) is a static hold — a value applied at one point, not an animated keyframe — so it must not synthesize a phantom keyframe. The prior fix only skipped GLOBAL gsap.set; on-timeline tl.set holds (and ones a split/conversion produced) still showed a diamond. Skip every set, which also aligns the AST keyframe cache with the runtime scan (it already drops every zero-duration set). --- packages/studio/src/hooks/useGsapTweenCache.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/packages/studio/src/hooks/useGsapTweenCache.ts b/packages/studio/src/hooks/useGsapTweenCache.ts index 24b537837..985511271 100644 --- a/packages/studio/src/hooks/useGsapTweenCache.ts +++ b/packages/studio/src/hooks/useGsapTweenCache.ts @@ -27,14 +27,12 @@ function deduplicateKeyframes(keyframes: GsapPercentageKeyframe[]): GsapPercenta // fallow-ignore-next-line complexity function synthesizeFlatTweenKeyframes(anim: GsapAnimation): GsapKeyframesData | null { if (anim.method === "set") { - // A base `gsap.set` is an off-timeline static hold (no position) — it must NOT - // synthesize a 0% keyframe, or the timeline + panel would show a phantom - // keyframe diamond for a value that isn't animated. - if (anim.global) return null; - return { - format: "percentage", - keyframes: [{ percentage: 0, properties: { ...anim.properties } }], - }; + // A `set` is a STATIC HOLD — a value applied at one point, not an animated + // keyframe. It must NOT synthesize a keyframe, or the timeline + panel show a + // phantom diamond for a value that doesn't animate. This holds for a base + // `gsap.set` (off-timeline) AND an on-timeline `tl.set`, and aligns the AST + // path with the runtime scan, which already skips every zero-duration set. + return null; } const toProps = anim.properties; const fromProps = anim.fromProperties; From caf05ed836b62d13eb1752be595b5b8f3f749097 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 25 Jun 2026 16:45:42 -0400 Subject: [PATCH 22/33] fix(studio): batch set-property edits (reset 3D no longer 404s) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reset 3D fires 6 props (rotationX/Y/Z, z, scale, perspective) at a set; commitSetProps updated them one at a time. A set's id is GROUP-derived, so the moment scale lands on a rotation set its id shifts (-other -> mixed), 404-ing the next prop (perspective never got set). Add an update-properties mutation (merge many props in one call) and have commitSetProps/commitStaticSet use it — one round-trip, no mid-loop id shift. --- packages/core/src/studio-api/routes/files.ts | 22 ++++++ .../src/hooks/useAnimatedPropertyCommit.ts | 72 +++++++++---------- 2 files changed, 57 insertions(+), 37 deletions(-) diff --git a/packages/core/src/studio-api/routes/files.ts b/packages/core/src/studio-api/routes/files.ts index 4fe91f0b9..08d4fdd20 100644 --- a/packages/core/src/studio-api/routes/files.ts +++ b/packages/core/src/studio-api/routes/files.ts @@ -428,6 +428,14 @@ type GsapMutationRequest = property: string; value: number | string; } + | { + // Merge MULTIPLE properties into an animation in ONE call. A per-property + // loop on a `set` can shift its group-derived id mid-way (e.g. adding `scale` + // to a rotation set), 404-ing the next update; this lands them all at once. + type: "update-properties"; + animationId: string; + properties: Record; + } | { type: "update-from-property"; animationId: string; @@ -701,6 +709,13 @@ function executeGsapMutationAcorn( properties: { ...r.anim.properties, [body.property]: val }, }); } + case "update-properties": { + const r = requireAnimation(block.scriptText, body.animationId); + if ("err" in r) return r.err; + return updateAnimationInScript(block.scriptText, body.animationId, { + properties: { ...r.anim.properties, ...body.properties }, + }); + } case "update-from-property": case "add-from-property": { const r = requireFromToAnimation(block.scriptText, body.animationId); @@ -985,6 +1000,13 @@ async function executeGsapMutationRecast( properties: { ...r.anim.properties, [body.property]: val }, }); } + case "update-properties": { + const r = requireAnimation(block.scriptText, body.animationId); + if ("err" in r) return r.err; + return updateAnimationInScript(block.scriptText, body.animationId, { + properties: { ...r.anim.properties, ...body.properties }, + }); + } case "update-from-property": case "add-from-property": { const r = requireFromToAnimation(block.scriptText, body.animationId); diff --git a/packages/studio/src/hooks/useAnimatedPropertyCommit.ts b/packages/studio/src/hooks/useAnimatedPropertyCommit.ts index edfe4aa69..fb69f9711 100644 --- a/packages/studio/src/hooks/useAnimatedPropertyCommit.ts +++ b/packages/studio/src/hooks/useAnimatedPropertyCommit.ts @@ -72,21 +72,6 @@ function commitPathLabel(anim: GsapAnimation | undefined): string { return anim.keyframes ? "keyframe" : "convert+keyframe"; } -/** The in-place patch for a value-only commit (no soft reload), or none. A global - * (`gsap.set`) hold has no runtime tween, so it applies straight to the element. */ -function setInstantPatch( - selector: string | null, - property: string, - value: number | string, - global = false, -): { selector: string; change: { kind: "set" | "global-set"; props: SetPatchProps } } | undefined { - if (!selector || typeof value !== "number") return undefined; - return { - selector, - change: { kind: global ? "global-set" : "set", props: { [property]: value } as SetPatchProps }, - }; -} - /** * Auto-keyframe a just-updated static `set`: if the element is already animated * (its clip carries keyframes on another tween), convert the set to keyframes so @@ -115,7 +100,10 @@ async function maybeAutoKeyframeSet( type Commit = NonNullable; -/** Merge each prop into the static `set` (value-only, instant), then auto-keyframe. */ +/** Merge ALL props into the static `set` in ONE commit (value-only, instant), then + * auto-keyframe. One mutation — a per-property loop would shift the set's + * group-derived id mid-way (e.g. reset adding `scale` to a rotation set), 404-ing + * the next update. */ async function commitSetProps( selection: DomEditSelection, setAnim: GsapAnimation, @@ -124,14 +112,26 @@ async function commitSetProps( animations: GsapAnimation[], commit: Commit, ): Promise { - for (const [property, value] of propEntries) { - const instantPatch = setInstantPatch(selector, property, value, !!setAnim.global); - await commit( - selection, - { type: "update-property", animationId: setAnim.id, property, value }, - { label: `Set ${property}`, softReload: true, ...(instantPatch ? { instantPatch } : {}) }, - ); + const properties = Object.fromEntries(propEntries); + const numericProps: SetPatchProps = {}; + for (const [k, v] of propEntries) { + if (typeof v === "number") numericProps[k as keyof SetPatchProps] = v; } + const instantPatch = + selector && Object.keys(numericProps).length > 0 + ? { + selector, + change: { + kind: (setAnim.global ? "global-set" : "set") as "set" | "global-set", + props: numericProps, + }, + } + : undefined; + await commit( + selection, + { type: "update-properties", animationId: setAnim.id, properties }, + { label: "Set 3D transform", softReload: true, ...(instantPatch ? { instantPatch } : {}) }, + ); await maybeAutoKeyframeSet(selection, setAnim, animations, commit); } @@ -149,21 +149,14 @@ async function commitStaticSet( commit: Commit, ): Promise { if (!selector) return; - // Only ever update an existing `set` (its id is position-based, so it's stable as - // properties are added) — NEVER a flat `to`/`from`, whose id is group-derived and - // shifts the instant a new-group prop is added, 404-ing the next axis and - // polluting an unrelated tween (e.g. a scale pop). A static element with no set - // gets a dedicated `set` carrying ALL props in ONE `add` (no per-prop id race). + // Update an existing `set` in ONE batched commit — NEVER a flat `to`/`from`. A + // set's id is GROUP-derived, so a per-prop loop shifts it the instant a new-group + // prop lands (e.g. `scale` onto a rotation set), 404-ing the next prop; commitSetProps + // sends them together. A static element with no set gets a dedicated `set` carrying + // ALL props in ONE `add`. const existingSet = animations.find((a) => a.method === "set" && a.targetSelector === selector); if (existingSet) { - for (const [property, value] of propEntries) { - const instantPatch = setInstantPatch(selector, property, value); - await commit( - selection, - { type: "update-property", animationId: existingSet.id, property, value }, - { label: `Set ${property}`, softReload: true, ...(instantPatch ? { instantPatch } : {}) }, - ); - } + await commitSetProps(selection, existingSet, propEntries, selector, animations, commit); return; } // Base `gsap.set` (off-timeline) — a static hold with no 0% keyframe marker, so @@ -188,7 +181,12 @@ async function commitStaticSet( label: "Set 3D transform", softReload: true, ...(Object.keys(numericProps).length > 0 - ? { instantPatch: { selector, change: { kind: "global-set" as const, props: numericProps } } } + ? { + instantPatch: { + selector, + change: { kind: "global-set" as const, props: numericProps }, + }, + } : {}), }, ); From 9026af41a53698502d0d6aec56799f6885433fdd Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 25 Jun 2026 17:03:48 -0400 Subject: [PATCH 23/33] style(studio): fix format + trim 3D-patch helper complexity oxfmt the runtime-patch file (the failing Format/Preflight check) and reduce the complexity of the new helpers: flatten keyframeVarsCarryChannel with .some, extract finiteNumericProps from applyGlobalSet, suppress the inherently-defensive rebuildKeyframeTween guard chain. --- .../studio/src/hooks/gsapRuntimeKeyframes.ts | 14 +++++----- packages/studio/src/hooks/gsapRuntimePatch.ts | 26 ++++++++++++------- 2 files changed, 22 insertions(+), 18 deletions(-) diff --git a/packages/studio/src/hooks/gsapRuntimeKeyframes.ts b/packages/studio/src/hooks/gsapRuntimeKeyframes.ts index 1a76fc153..ece278372 100644 --- a/packages/studio/src/hooks/gsapRuntimeKeyframes.ts +++ b/packages/studio/src/hooks/gsapRuntimeKeyframes.ts @@ -209,14 +209,12 @@ function keyframeVarsCarryChannel( const kf = vars?.keyframes; if (!kf || typeof kf !== "object") return false; const steps = Array.isArray(kf) ? kf : Object.values(kf); - for (const step of steps) { - if (step && typeof step === "object") { - for (const ch of channels) { - if (Object.prototype.hasOwnProperty.call(step, ch)) return true; - } - } - } - return false; + return steps.some( + (step) => + step != null && + typeof step === "object" && + channels.some((ch) => Object.prototype.hasOwnProperty.call(step, ch)), + ); } /** diff --git a/packages/studio/src/hooks/gsapRuntimePatch.ts b/packages/studio/src/hooks/gsapRuntimePatch.ts index ac7ac0c8f..a95f248f9 100644 --- a/packages/studio/src/hooks/gsapRuntimePatch.ts +++ b/packages/studio/src/hooks/gsapRuntimePatch.ts @@ -79,22 +79,26 @@ type IframeWindow = Window & { * no tween to patch, so we set the channels directly. Safe because the element is * static on these channels (the caller only uses this for non-animated values). */ +/** The props as finite numbers, or null if any value is non-finite / none present. */ +function finiteNumericProps(props: SetPatchProps): Record | null { + const numeric: Record = {}; + for (const [k, v] of Object.entries(props)) { + if (typeof v !== "number" || !Number.isFinite(v)) return null; + numeric[k] = v; + } + return Object.keys(numeric).length > 0 ? numeric : null; +} + function applyGlobalSet( iframe: HTMLIFrameElement, selector: string, props: SetPatchProps, ): boolean { try { - const win = iframe.contentWindow as IframeWindow | null; - const gsapLib = win?.gsap; + const gsapLib = (iframe.contentWindow as IframeWindow | null)?.gsap; const el = iframe.contentDocument?.querySelector(selector) ?? null; - if (!gsapLib?.set || !el) return false; - const numeric: Record = {}; - for (const [k, v] of Object.entries(props)) { - if (typeof v !== "number" || !Number.isFinite(v)) return false; - numeric[k] = v; - } - if (Object.keys(numeric).length === 0) return false; + const numeric = finiteNumericProps(props); + if (!gsapLib?.set || !el || !numeric) return false; gsapLib.set(el, numeric); return true; } catch { @@ -186,6 +190,7 @@ function mergeKeyframeStep( * mutations. Declines (→ caller soft-reloads) for array-form, motionPath arcs, * non-finite/dynamic values, or a tween whose parent/targets can't be resolved. */ +// fallow-ignore-next-line complexity function rebuildKeyframeTween(tween: RuntimeTween, pct: number, props: KeyframeStep): boolean { const vars = tween.vars; if (!vars || "motionPath" in vars) return false; @@ -235,7 +240,8 @@ function seekToCurrent(iframe: HTMLIFrameElement, timeline: RuntimeTimeline): vo function applyChange(tween: RuntimeTween, change: RuntimeTweenChange): boolean { if (change.kind === "set") return patchSet(tween, change.props); if (change.kind === "keyframes") return patchKeyframes(tween, change.keyframes); - if (change.kind === "keyframe-rebuild") return rebuildKeyframeTween(tween, change.pct, change.props); + if (change.kind === "keyframe-rebuild") + return rebuildKeyframeTween(tween, change.pct, change.props); return false; } From 4257c6cf6d7c2780785262dbdd936788ee3fa05d Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 25 Jun 2026 17:08:11 -0400 Subject: [PATCH 24/33] chore(studio): remove [hf-3d:*] debug logs (3D transform verified working) Strip the log3d call sites + the debug3d util now that the 3D transform / static-set / keyframe-rebuild paths are confirmed working. --- .../editor/propertyPanel3dTransform.tsx | 7 ----- .../src/hooks/useAnimatedPropertyCommit.ts | 21 +------------ .../studio/src/hooks/useGsapScriptCommits.ts | 12 ------- packages/studio/src/utils/debug3d.ts | 31 ------------------- 4 files changed, 1 insertion(+), 70 deletions(-) delete mode 100644 packages/studio/src/utils/debug3d.ts diff --git a/packages/studio/src/components/editor/propertyPanel3dTransform.tsx b/packages/studio/src/components/editor/propertyPanel3dTransform.tsx index e3e4c51e2..102c593d8 100644 --- a/packages/studio/src/components/editor/propertyPanel3dTransform.tsx +++ b/packages/studio/src/components/editor/propertyPanel3dTransform.tsx @@ -5,7 +5,6 @@ import { MetricField } from "./propertyPanelPrimitives"; import { KeyframeNavigation } from "./KeyframeNavigation"; import { formatPxMetricValue, parsePxMetricValue, RESPONSIVE_GRID } from "./propertyPanelHelpers"; import { Transform3DCube, type CubePose } from "./Transform3DCube"; -import { log3d } from "../../utils/debug3d"; type KeyframeEntry = Array<{ percentage: number; @@ -81,12 +80,6 @@ function Cube3dControl({ if (rounded !== Math.round(pose[axis])) changedProps[axis] = rounded; } const axes = Object.keys(changedProps); - log3d("cube-commit-pose", { - from: pose, - to: next, - changedAxes: axes, - batched: !!onCommitAnimatedProperties, - }); if (axes.length === 0) return; // ONE keyframe for the whole pose change — avoids per-axis commits racing into // adjacent duplicate keyframes. Fall back to per-axis if no batched commit. diff --git a/packages/studio/src/hooks/useAnimatedPropertyCommit.ts b/packages/studio/src/hooks/useAnimatedPropertyCommit.ts index fb69f9711..42b76a2b3 100644 --- a/packages/studio/src/hooks/useAnimatedPropertyCommit.ts +++ b/packages/studio/src/hooks/useAnimatedPropertyCommit.ts @@ -14,7 +14,6 @@ import type { DomEditSelection } from "../components/editor/domEditingTypes"; import { usePlayerStore } from "../player/store/playerStore"; import { readAllAnimatedProperties, readGsapProperty } from "./gsapRuntimeBridge"; import type { SetPatchProps } from "./gsapRuntimePatch"; -import { log3d } from "../utils/debug3d"; import { selectorFromSelection, computeElementPercentage } from "./gsapShared"; interface CommitAnimatedPropertyDeps { @@ -65,13 +64,6 @@ function pickBestAnimation( return scored[0]?.anim; } -/** Which commit branch a property edit will take, for the debug log. */ -function commitPathLabel(anim: GsapAnimation | undefined): string { - if (!anim) return "create"; - if (anim.method === "set") return "static-set"; - return anim.keyframes ? "keyframe" : "convert+keyframe"; -} - /** * Auto-keyframe a just-updated static `set`: if the element is already animated * (its clip carries keyframes on another tween), convert the set to keyframes so @@ -86,7 +78,6 @@ async function maybeAutoKeyframeSet( ): Promise { const animatedTween = animations.find((a) => a.keyframes && a.id !== setAnim.id); if (!animatedTween) return; - log3d("auto-keyframe", { animationId: setAnim.id, duration: animatedTween.duration ?? 1 }); await commit( selection, { @@ -271,15 +262,6 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) { selector, primaryProp, ); - log3d("commit-prop", { - props, - selector, - pickedAnim: anim - ? { id: anim.id, method: anim.method, hasKeyframes: !!anim.keyframes } - : null, - path: commitPathLabel(anim), - }); - // Whether the element is animated at all. A 3D edit only creates/edits // keyframes when it IS — a static element (no keyframes on any of its tweens) // gets a `tl.set`, never new keyframes (matches manual drag / resize / rotate). @@ -334,8 +316,7 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) { iframe, gsapCommitMutation, ); - } catch (error) { - log3d("commit-prop", { error: String(error), stale: anim?.id, action: "bump-cache" }); + } catch { bumpGsapCache(); } }, diff --git a/packages/studio/src/hooks/useGsapScriptCommits.ts b/packages/studio/src/hooks/useGsapScriptCommits.ts index 718e4bd18..a0fa4495f 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.ts +++ b/packages/studio/src/hooks/useGsapScriptCommits.ts @@ -22,7 +22,6 @@ import { useGsapAnimationOps } from "./useGsapAnimationOps"; import { useGsapArcPathOps } from "./useGsapArcPathOps"; import { useGsapKeyframeOps } from "./useGsapKeyframeOps"; import { useGsapPropertyDebounce } from "./useGsapPropertyDebounce"; -import { log3d } from "../utils/debug3d"; import { useGsapSaveFailureTelemetry, useSafeGsapCommitMutation, @@ -98,23 +97,12 @@ export function applyPreviewSync( options.instantPatch.selector, options.instantPatch.change, ); - log3d("preview-sync", { - label: options.label, - mode: patched ? "instant (no flash)" : "reload-fallback (FLASH)", - selector: options.instantPatch.selector, - change: options.instantPatch.change, - }); // Patched in place — element is already correct on screen; no reload needed. if (patched) return; // The instant path couldn't patch in place — record the fallback so we can // track how often the fast path misses before the soft/full reload below. trackStudioEvent("gsap_instant_patch_fallback", { selector: options.instantPatch.selector }); // Fall through to the soft/full reload path below. - } else { - log3d("preview-sync", { - label: options.label, - mode: options.softReload ? "soft-reload (FLASH)" : "full-reload (FLASH)", - }); } if (options.softReload && result.scriptText) { // A soft-reloadable edit escalates to a full iframe remount ONLY on the diff --git a/packages/studio/src/utils/debug3d.ts b/packages/studio/src/utils/debug3d.ts deleted file mode 100644 index a7e39b239..000000000 --- a/packages/studio/src/utils/debug3d.ts +++ /dev/null @@ -1,31 +0,0 @@ -// Gated, JSON-stringified debug logging for the 3D-transform commit/flash path. -// Silent in production; on in dev builds, or anywhere once you set -// `window.__hfDebug = true` in the console. Single `[hf-3d:]` prefix so -// the whole 3D commit pipeline is greppable, and objects are stringified so they -// survive copy/paste out of the console. -function debugEnabled(): boolean { - try { - if ((window as unknown as { __hfDebug?: boolean }).__hfDebug) return true; - } catch { - /* no window (SSR) */ - } - try { - return import.meta.env?.DEV === true; - } catch { - return false; - } -} - -export function log3d(scope: string, data?: unknown): void { - if (!debugEnabled()) return; - let payload = ""; - if (data !== undefined) { - try { - payload = typeof data === "string" ? data : JSON.stringify(data); - } catch { - payload = String(data); - } - } - // eslint-disable-next-line no-console -- intentional opt-in debug surface - console.log(`[hf-3d:${scope}]`, payload); -} From b57f1fed9666319369de4044cda3ef4e7b8f3b8c Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 25 Jun 2026 17:13:04 -0400 Subject: [PATCH 25/33] chore(studio): strategic [hf-pos:*] logs for position-commit path audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Temporary DEV-gated logs to confirm which path each drag takes: single drag → GSAP code path (single-gsap), multi-select/group drag → DEPRECATED CSS-var path (group-css, applyStudioPathOffset → --hf-studio-offset), and the single CSS fallback (single-css). To be removed once group drag is routed through GSAP. --- .../studio/src/hooks/useDomGeometryCommits.ts | 11 +++++++++- .../studio/src/hooks/useGsapAwareEditing.ts | 6 ++++++ packages/studio/src/utils/debugPos.ts | 20 +++++++++++++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 packages/studio/src/utils/debugPos.ts diff --git a/packages/studio/src/hooks/useDomGeometryCommits.ts b/packages/studio/src/hooks/useDomGeometryCommits.ts index ffe3822da..c4d1fb3ea 100644 --- a/packages/studio/src/hooks/useDomGeometryCommits.ts +++ b/packages/studio/src/hooks/useDomGeometryCommits.ts @@ -19,6 +19,7 @@ import { import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditOverlay"; import type { PatchOperation } from "../utils/sourcePatcher"; import { isElementGsapTargeted } from "./gsapTargetCache"; +import { logPos } from "../utils/debugPos"; export const GSAP_CSS_FALLBACK_BLOCKED_MESSAGE = "This element is GSAP-animated — dragging via CSS would corrupt keyframes"; @@ -46,7 +47,9 @@ export function useDomGeometryCommits({ // elements fall through to commitPositionPatchToHtml → persistDomEditOperations → // onTrySdkPersist and are already SDK-cut-over as setStyle/setAttribute (§3.3 done). // Upgrade path for GSAP: add a moveElementGsap SDK op in a separate SDK PR. - if (isElementGsapTargeted(previewIframeRef.current, selection.element)) { + const gsapTargeted = isElementGsapTargeted(previewIframeRef.current, selection.element); + logPos("single-css", { target: getDomEditTargetKey(selection), gsapTargeted, blocked: gsapTargeted }); + if (gsapTargeted) { const error = new Error(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE); showToast(error.message, "error"); return Promise.reject(error); @@ -66,6 +69,12 @@ export function useDomGeometryCommits({ const blockedUpdate = updates.find(({ selection }) => isElementGsapTargeted(previewIframeRef.current, selection.element), ); + logPos("group-css", { + count: updates.length, + targets: updates.map((u) => getDomEditTargetKey(u.selection)), + anyGsapTargeted: !!blockedUpdate, + path: "DEPRECATED css-var (applyStudioPathOffset → --hf-studio-offset)", + }); if (blockedUpdate) { const error = new Error(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE); showToast(error.message, "error"); diff --git a/packages/studio/src/hooks/useGsapAwareEditing.ts b/packages/studio/src/hooks/useGsapAwareEditing.ts index 21d74ebd6..a69ca6fa9 100644 --- a/packages/studio/src/hooks/useGsapAwareEditing.ts +++ b/packages/studio/src/hooks/useGsapAwareEditing.ts @@ -21,6 +21,7 @@ import { useSafeGsapCommitMutation, } from "./useSafeGsapCommitMutation"; import type { CommitMutation } from "./gsapScriptCommitTypes"; +import { logPos } from "../utils/debugPos"; export interface UseGsapAwareEditingParams { domEditSelection: DomEditSelection | null; @@ -96,6 +97,11 @@ export function useGsapAwareEditing({ next: { x: number; y: number }, modifiers?: { altKey?: boolean }, ) => { + logPos("single-gsap", { + target: selection.id ?? selection.selector ?? "?", + hasGsapCommit: !!gsapCommitMutation, + path: gsapCommitMutation ? "GSAP code path (tryGsapDragIntercept)" : "NO-OP (no gsap commit)", + }); if (gsapCommitMutation) { try { await tryGsapDragIntercept( diff --git a/packages/studio/src/utils/debugPos.ts b/packages/studio/src/utils/debugPos.ts new file mode 100644 index 000000000..cba2d83e4 --- /dev/null +++ b/packages/studio/src/utils/debugPos.ts @@ -0,0 +1,20 @@ +/** + * Temporary strategic logging for the position-commit path investigation: + * which commits go through the GSAP code path (writes `tl.set`/keyframes/`gsap.set`) + * vs. the deprecated CSS-var path (`applyStudioPathOffset` → `--hf-studio-offset`). + * + * Gated on `window.__hfDebug || import.meta.env.DEV`, prefix `[hf-pos:]`. + * Remove once the CSS-var path is eliminated. + */ +declare global { + interface Window { + __hfDebug?: boolean; + } +} + +export function logPos(scope: string, data?: unknown): void { + if (typeof window === "undefined") return; + if (!window.__hfDebug && !import.meta.env.DEV) return; + // eslint-disable-next-line no-console + console.log(`[hf-pos:${scope}]`, data === undefined ? "" : JSON.stringify(data)); +} From 8d87930e801a0643c79d7751625fe7fdb10eb0b4 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 25 Jun 2026 17:24:10 -0400 Subject: [PATCH 26/33] fix(studio): route multi-select group drag through GSAP code path Group drag committed positions via the deprecated --hf-studio-offset CSS var (applyStudioPathOffset) and outright blocked GSAP-animated elements. Single drag already routes through tryGsapDragIntercept (tl.set / keyframes / gsap.set); group drag now does the same per element, so a multi-select move writes real GSAP code with no CSS-var fallback. Removed the now-dead CSS group commit. --- .../studio/src/hooks/useDomEditCommits.ts | 2 - .../studio/src/hooks/useDomEditSession.ts | 4 +- .../studio/src/hooks/useDomGeometryCommits.ts | 41 +++---------------- .../studio/src/hooks/useGsapAwareEditing.ts | 33 ++++++++++++++- 4 files changed, 39 insertions(+), 41 deletions(-) diff --git a/packages/studio/src/hooks/useDomEditCommits.ts b/packages/studio/src/hooks/useDomEditCommits.ts index 8492b0ab9..38ab0c0b9 100644 --- a/packages/studio/src/hooks/useDomEditCommits.ts +++ b/packages/studio/src/hooks/useDomEditCommits.ts @@ -301,7 +301,6 @@ export function useDomEditCommits({ const { handleDomPathOffsetCommit, - handleDomGroupPathOffsetCommit, handleDomBoxSizeCommit, handleDomRotationCommit, handleDomManualEditsReset, @@ -340,7 +339,6 @@ export function useDomEditCommits({ handleDomAddTextField, handleDomRemoveTextField, handleDomPathOffsetCommit, - handleDomGroupPathOffsetCommit, handleDomBoxSizeCommit, handleDomRotationCommit, handleDomManualEditsReset, diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index 28c5288ac..96ffdbc09 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -215,7 +215,6 @@ export function useDomEditSession({ handleDomTextFieldStyleCommit, handleDomAddTextField, handleDomRemoveTextField, - handleDomGroupPathOffsetCommit, handleDomBoxSizeCommit, handleDomManualEditsReset, handleDomEditElementDelete, @@ -368,6 +367,7 @@ export function useDomEditSession({ const { handleGsapAwarePathOffsetCommit, + handleGsapAwareGroupPathOffsetCommit, handleGsapAwareBoxSizeCommit, handleGsapAwareRotationCommit, commitAnimatedProperty, @@ -453,7 +453,7 @@ export function useDomEditSession({ handleDomAttributeLiveCommit, handleDomHtmlAttributeCommit, handleDomPathOffsetCommit: handleGsapAwarePathOffsetCommit, - handleDomGroupPathOffsetCommit, + handleDomGroupPathOffsetCommit: handleGsapAwareGroupPathOffsetCommit, handleDomZIndexReorderCommit, handleDomBoxSizeCommit: handleGsapAwareBoxSizeCommit, handleDomRotationCommit: handleGsapAwareRotationCommit, diff --git a/packages/studio/src/hooks/useDomGeometryCommits.ts b/packages/studio/src/hooks/useDomGeometryCommits.ts index c4d1fb3ea..d2119f3da 100644 --- a/packages/studio/src/hooks/useDomGeometryCommits.ts +++ b/packages/studio/src/hooks/useDomGeometryCommits.ts @@ -16,7 +16,6 @@ import { buildClearBoxSizePatches, buildClearRotationPatches, } from "../components/editor/manualEditsDomPatches"; -import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditOverlay"; import type { PatchOperation } from "../utils/sourcePatcher"; import { isElementGsapTargeted } from "./gsapTargetCache"; import { logPos } from "../utils/debugPos"; @@ -48,7 +47,11 @@ export function useDomGeometryCommits({ // onTrySdkPersist and are already SDK-cut-over as setStyle/setAttribute (§3.3 done). // Upgrade path for GSAP: add a moveElementGsap SDK op in a separate SDK PR. const gsapTargeted = isElementGsapTargeted(previewIframeRef.current, selection.element); - logPos("single-css", { target: getDomEditTargetKey(selection), gsapTargeted, blocked: gsapTargeted }); + logPos("single-css", { + target: getDomEditTargetKey(selection), + gsapTargeted, + blocked: gsapTargeted, + }); if (gsapTargeted) { const error = new Error(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE); showToast(error.message, "error"); @@ -63,39 +66,6 @@ export function useDomGeometryCommits({ [commitPositionPatchToHtml, previewIframeRef, showToast], ); - const handleDomGroupPathOffsetCommit = useCallback( - (updates: DomEditGroupPathOffsetCommit[]) => { - if (updates.length === 0) return Promise.resolve(); - const blockedUpdate = updates.find(({ selection }) => - isElementGsapTargeted(previewIframeRef.current, selection.element), - ); - logPos("group-css", { - count: updates.length, - targets: updates.map((u) => getDomEditTargetKey(u.selection)), - anyGsapTargeted: !!blockedUpdate, - path: "DEPRECATED css-var (applyStudioPathOffset → --hf-studio-offset)", - }); - if (blockedUpdate) { - const error = new Error(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE); - showToast(error.message, "error"); - return Promise.reject(error); - } - const coalesceKey = updates - .map((u) => getDomEditTargetKey(u.selection)) - .sort() - .join(":"); - const saves = updates.map(({ selection, next }) => { - applyStudioPathOffset(selection.element, next); - return commitPositionPatchToHtml(selection, buildPathOffsetPatches(selection.element), { - label: `Move ${updates.length} layers`, - coalesceKey: `group-path-offset:${coalesceKey}`, - }); - }); - return Promise.all(saves).then(() => undefined); - }, - [commitPositionPatchToHtml, previewIframeRef, showToast], - ); - const handleDomBoxSizeCommit = useCallback( (selection: DomEditSelection, next: { width: number; height: number }) => { if (isElementGsapTargeted(previewIframeRef.current, selection.element)) { @@ -151,7 +121,6 @@ export function useDomGeometryCommits({ return { handleDomPathOffsetCommit, - handleDomGroupPathOffsetCommit, handleDomBoxSizeCommit, handleDomRotationCommit, handleDomManualEditsReset, diff --git a/packages/studio/src/hooks/useGsapAwareEditing.ts b/packages/studio/src/hooks/useGsapAwareEditing.ts index a69ca6fa9..c90d9bacc 100644 --- a/packages/studio/src/hooks/useGsapAwareEditing.ts +++ b/packages/studio/src/hooks/useGsapAwareEditing.ts @@ -21,6 +21,7 @@ import { useSafeGsapCommitMutation, } from "./useSafeGsapCommitMutation"; import type { CommitMutation } from "./gsapScriptCommitTypes"; +import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditOverlay"; import { logPos } from "../utils/debugPos"; export interface UseGsapAwareEditingParams { @@ -100,7 +101,9 @@ export function useGsapAwareEditing({ logPos("single-gsap", { target: selection.id ?? selection.selector ?? "?", hasGsapCommit: !!gsapCommitMutation, - path: gsapCommitMutation ? "GSAP code path (tryGsapDragIntercept)" : "NO-OP (no gsap commit)", + path: gsapCommitMutation + ? "GSAP code path (tryGsapDragIntercept)" + : "NO-OP (no gsap commit)", }); if (gsapCommitMutation) { try { @@ -128,6 +131,33 @@ export function useGsapAwareEditing({ ], ); + // Multi-select (group) drag: route EACH element through the SAME GSAP intercept as + // a single drag, so every position is written as GSAP code (tl.set / keyframes / + // gsap.set) — NEVER the deprecated `--hf-studio-offset` CSS var, and GSAP-animated + // elements are no longer blocked in a group. No CSS fallback: with no GSAP + // composition there's nothing to write (a no-op, exactly like the single-drag path). + const handleGsapAwareGroupPathOffsetCommit = useCallback( + async (updates: DomEditGroupPathOffsetCommit[]) => { + if (!gsapCommitMutation) return; + for (const { selection, next } of updates) { + try { + await tryGsapDragIntercept( + selection, + next, + [], + previewIframeRef.current, + gsapCommitMutation, + makeFetchFallback(selection), + ); + } catch (error) { + trackGsapInteractionFailure(error, selection, "drag", "Move animated layer (group)"); + throw error; + } + } + }, + [gsapCommitMutation, previewIframeRef, makeFetchFallback, trackGsapInteractionFailure], + ); + const handleGsapAwareBoxSizeCommit = useCallback( async (selection: DomEditSelection, next: { width: number; height: number }) => { if (gsapCommitMutation) { @@ -252,6 +282,7 @@ export function useGsapAwareEditing({ return { handleGsapAwarePathOffsetCommit, + handleGsapAwareGroupPathOffsetCommit, handleGsapAwareBoxSizeCommit, handleGsapAwareRotationCommit, commitAnimatedProperty, From 361485a7bcb34148f6b931b24875e515f16d3684 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 25 Jun 2026 17:28:33 -0400 Subject: [PATCH 27/33] feat(studio): live candidate highlight while marquee-selecting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The marquee only revealed what it selected on mouse-up, so it was easy to grab too much or too little. Now each element the marquee box currently intersects is outlined live (studio-accent) as you drag, before release — so you can see the selection forming. Shares one synchronous OBB/SAT intersection pass between the live highlight and the commit; the async source-probe still runs only once, on mouse-up. --- .../src/components/editor/DomEditOverlay.tsx | 8 ++ .../src/components/editor/marqueeCommit.ts | 84 +++++++++++++++---- packages/studio/src/utils/marqueeGeometry.ts | 2 +- 3 files changed, 76 insertions(+), 18 deletions(-) diff --git a/packages/studio/src/components/editor/DomEditOverlay.tsx b/packages/studio/src/components/editor/DomEditOverlay.tsx index 188d474dd..1663fe636 100644 --- a/packages/studio/src/components/editor/DomEditOverlay.tsx +++ b/packages/studio/src/components/editor/DomEditOverlay.tsx @@ -568,6 +568,14 @@ export const DomEditOverlay = memo(function DomEditOverlay({ activeCompositionPathRef={activeCompositionPathRef} onSelectionChangeRef={onSelectionChangeRef} /> + {marquee.candidateRects.map((r, i) => ( +