Skip to content

Commit 663f43e

Browse files
committed
feat(studio): scale GSAP positions on clip resize + shift on drag + diamond fixes
Resize: proportionally scale all GSAP animation positions and durations to fit the new clip duration via scalePositionsInScript. This preserves clip-relative keyframe percentages — diamonds don't move during resize, nothing disappears. Modeled after After Effects Time Stretch behavior. Drag: shift all GSAP positions by the time delta (unchanged from before). Diamond rendering: - Clamp diamonds at 0%/100% so they stay fully visible at clip edges - Filter out-of-range keyframes using predicted percentages during resize - Clamp connection lines to clip boundaries - PropertyRows: same edge clamping for SVG diamonds Parser: scalePositionsInScript (proportional position + duration scaling), shiftPositionsInScript (rigid shift), scale-positions + shift-positions mutation types, 5 shift tests passing.
1 parent f1a50e0 commit 663f43e

8 files changed

Lines changed: 330 additions & 13 deletions

File tree

packages/core/src/parsers/gsapParser.test.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import {
1919
addAnimationWithKeyframesToScript,
2020
splitAnimationsInScript,
2121
splitIntoPropertyGroups,
22+
shiftPositionsInScript,
23+
scalePositionsInScript,
2224
} from "./gsapParser.js";
2325
import type { GsapAnimation } from "./gsapParser.js";
2426
import { classifyPropertyGroup, classifyTweenPropertyGroup } from "./gsapConstants.js";
@@ -2275,3 +2277,113 @@ describe("splitIntoPropertyGroups", () => {
22752277
}
22762278
});
22772279
});
2280+
2281+
describe("shiftPositionsInScript", () => {
2282+
it("shifts all numeric positions for the target selector", () => {
2283+
const script = `const tl = gsap.timeline({ paused: true });
2284+
tl.from("#hero", { opacity: 0, duration: 1 }, 0);
2285+
tl.to("#hero", { opacity: 0, duration: 0.5 }, 2.5);
2286+
tl.from("#bg", { scale: 0, duration: 1 }, 1);`;
2287+
const result = shiftPositionsInScript(script, "#hero", 3);
2288+
const parsed = parseGsapScript(result);
2289+
const hero = parsed.animations.filter((a) => a.targetSelector === "#hero");
2290+
expect(hero[0].position).toBe(3);
2291+
expect(hero[1].position).toBe(5.5);
2292+
const bg = parsed.animations.find((a) => a.targetSelector === "#bg");
2293+
expect(bg!.position).toBe(1);
2294+
});
2295+
2296+
it("clamps negative-going positions to zero", () => {
2297+
const script = `const tl = gsap.timeline({ paused: true });
2298+
tl.to("#el", { x: 100, duration: 1 }, 0.3);
2299+
tl.to("#el", { y: 50, duration: 1 }, 1.5);`;
2300+
const result = shiftPositionsInScript(script, "#el", -1.0);
2301+
const parsed = parseGsapScript(result);
2302+
const anims = parsed.animations.filter((a) => a.targetSelector === "#el");
2303+
expect(anims[0].position).toBe(0);
2304+
expect(anims[1].position).toBe(0.5);
2305+
});
2306+
2307+
it("returns the original script when delta is zero", () => {
2308+
const script = `const tl = gsap.timeline({ paused: true });
2309+
tl.to("#el", { x: 100, duration: 1 }, 2);`;
2310+
expect(shiftPositionsInScript(script, "#el", 0)).toBe(script);
2311+
});
2312+
2313+
it("does not collide when two tweens have adjacent positions (Via's race case)", () => {
2314+
const script = `const tl = gsap.timeline({ paused: true });
2315+
tl.to("#burst", { opacity: 1, duration: 0.5 }, 1.0);
2316+
tl.to("#burst", { opacity: 0, duration: 0.5 }, 1.5);`;
2317+
const result = shiftPositionsInScript(script, "#burst", 0.5);
2318+
const parsed = parseGsapScript(result);
2319+
const burst = parsed.animations.filter((a) => a.targetSelector === "#burst");
2320+
expect(burst[0].position).toBe(1.5);
2321+
expect(burst[1].position).toBe(2);
2322+
});
2323+
2324+
it("skips string positions", () => {
2325+
const script = `const tl = gsap.timeline({ paused: true });
2326+
tl.to("#el", { x: 100, duration: 1 }, 2);
2327+
tl.to("#el", { y: 50, duration: 1 }, "+=0.5");`;
2328+
const result = shiftPositionsInScript(script, "#el", 1);
2329+
const parsed = parseGsapScript(result);
2330+
expect(parsed.animations[0].position).toBe(3);
2331+
expect(parsed.animations[1].position).toBe("+=0.5");
2332+
});
2333+
});
2334+
2335+
describe("scalePositionsInScript", () => {
2336+
it("scales positions and durations proportionally for the target selector", () => {
2337+
const script = `const tl = gsap.timeline({ paused: true });
2338+
tl.from("#hero", { opacity: 0, duration: 1 }, 0);
2339+
tl.to("#hero", { opacity: 0, duration: 0.5 }, 2.5);
2340+
tl.from("#bg", { scale: 0, duration: 1 }, 1);`;
2341+
const result = scalePositionsInScript(script, "#hero", 0, 3, 0, 2);
2342+
const parsed = parseGsapScript(result);
2343+
const hero = parsed.animations.filter((a) => a.targetSelector === "#hero");
2344+
expect(hero[0].position).toBe(0);
2345+
expect(hero[0].duration).toBeCloseTo(0.667, 2);
2346+
expect(hero[1].position).toBeCloseTo(1.667, 2);
2347+
expect(hero[1].duration).toBeCloseTo(0.333, 2);
2348+
const bg = parsed.animations.find((a) => a.targetSelector === "#bg");
2349+
expect(bg!.position).toBe(1);
2350+
expect(bg!.duration).toBe(1);
2351+
});
2352+
2353+
it("handles start-edge resize (new start + shorter duration)", () => {
2354+
const script = `const tl = gsap.timeline({ paused: true });
2355+
tl.from("#el", { opacity: 0, duration: 1 }, 0);
2356+
tl.to("#el", { y: 50, duration: 0.5 }, 2.5);`;
2357+
const result = scalePositionsInScript(script, "#el", 0, 3, 1, 2);
2358+
const parsed = parseGsapScript(result);
2359+
const anims = parsed.animations.filter((a) => a.targetSelector === "#el");
2360+
expect(anims[0].position).toBe(1);
2361+
expect(anims[0].duration).toBeCloseTo(0.667, 2);
2362+
expect(anims[1].position).toBeCloseTo(2.667, 2);
2363+
expect(anims[1].duration).toBeCloseTo(0.333, 2);
2364+
});
2365+
2366+
it("clamps negative-going positions to zero", () => {
2367+
const script = `const tl = gsap.timeline({ paused: true });
2368+
tl.to("#el", { x: 100, duration: 1 }, 2);`;
2369+
const result = scalePositionsInScript(script, "#el", 2, 1, 0, 0.5);
2370+
const parsed = parseGsapScript(result);
2371+
expect(parsed.animations[0].position).toBe(0);
2372+
});
2373+
2374+
it("returns the original script when old and new timing are identical", () => {
2375+
const script = `const tl = gsap.timeline({ paused: true });
2376+
tl.to("#el", { x: 100, duration: 1 }, 2);`;
2377+
expect(scalePositionsInScript(script, "#el", 0, 3, 0, 3)).toBe(script);
2378+
});
2379+
2380+
it("skips string positions", () => {
2381+
const script = `const tl = gsap.timeline({ paused: true });
2382+
tl.to("#el", { x: 100, duration: 1 }, 2);
2383+
tl.to("#el", { y: 50, duration: 1 }, "+=0.5");`;
2384+
const result = scalePositionsInScript(script, "#el", 0, 3, 0, 2);
2385+
const parsed = parseGsapScript(result);
2386+
expect(parsed.animations[0].position).toBeCloseTo(1.333, 2);
2387+
expect(parsed.animations[1].position).toBe("+=0.5");
2388+
});
2389+
});

packages/core/src/parsers/gsapParser.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1322,6 +1322,67 @@ export function updateAnimationInScript(
13221322
return recast.print(parsed.ast).code;
13231323
}
13241324

1325+
export function shiftPositionsInScript(
1326+
script: string,
1327+
targetSelector: string,
1328+
delta: number,
1329+
): string {
1330+
let parsed: ParsedGsapAst;
1331+
try {
1332+
parsed = parseGsapAst(script);
1333+
} catch (e) {
1334+
console.warn("[gsap-parser] shiftPositionsInScript parse failed:", e);
1335+
return script;
1336+
}
1337+
let changed = false;
1338+
for (const entry of parsed.located) {
1339+
if (entry.animation.targetSelector !== targetSelector) continue;
1340+
if (typeof entry.animation.position !== "number") continue;
1341+
const newPos = Math.max(0, Math.round((entry.animation.position + delta) * 1000) / 1000);
1342+
applyUpdatesToCall(entry.call, { position: newPos });
1343+
changed = true;
1344+
}
1345+
return changed ? recast.print(parsed.ast).code : script;
1346+
}
1347+
1348+
export function scalePositionsInScript(
1349+
script: string,
1350+
targetSelector: string,
1351+
oldStart: number,
1352+
oldDuration: number,
1353+
newStart: number,
1354+
newDuration: number,
1355+
): string {
1356+
if (oldDuration <= 0 || newDuration <= 0) return script;
1357+
const ratio = newDuration / oldDuration;
1358+
let parsed: ParsedGsapAst;
1359+
try {
1360+
parsed = parseGsapAst(script);
1361+
} catch (e) {
1362+
console.warn("[gsap-parser] scalePositionsInScript parse failed:", e);
1363+
return script;
1364+
}
1365+
let changed = false;
1366+
for (const entry of parsed.located) {
1367+
if (entry.animation.targetSelector !== targetSelector) continue;
1368+
if (typeof entry.animation.position !== "number") continue;
1369+
const newPos = Math.max(
1370+
0,
1371+
Math.round((newStart + (entry.animation.position - oldStart) * ratio) * 1000) / 1000,
1372+
);
1373+
const updates: Partial<GsapAnimation> = { position: newPos };
1374+
if (typeof entry.animation.duration === "number" && entry.animation.duration > 0) {
1375+
updates.duration = Math.max(
1376+
0.001,
1377+
Math.round(entry.animation.duration * ratio * 1000) / 1000,
1378+
);
1379+
}
1380+
applyUpdatesToCall(entry.call, updates);
1381+
changed = true;
1382+
}
1383+
return changed ? recast.print(parsed.ast).code : script;
1384+
}
1385+
13251386
function updateAnimationSelector(script: string, animationId: string, newSelector: string): string {
13261387
let parsed: ParsedGsapAst;
13271388
try {

packages/core/src/studio-api/routes/files.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -466,6 +466,19 @@ type GsapMutationRequest =
466466
| {
467467
type: "delete-all-for-selector";
468468
targetSelector: string;
469+
}
470+
| {
471+
type: "shift-positions";
472+
targetSelector: string;
473+
delta: number;
474+
}
475+
| {
476+
type: "scale-positions";
477+
targetSelector: string;
478+
oldStart: number;
479+
oldDuration: number;
480+
newStart: number;
481+
newDuration: number;
469482
};
470483

471484
// ── GSAP mutation executor ──────────────────────────────────────────────────
@@ -715,6 +728,26 @@ async function executeGsapMutation(
715728
const result = splitIntoPropertyGroups(block.scriptText, body.animationId);
716729
return result.script;
717730
}
731+
case "shift-positions": {
732+
const { targetSelector, delta } = body;
733+
if (!targetSelector || typeof delta !== "number" || delta === 0) return block.scriptText;
734+
const { shiftPositionsInScript } = parser;
735+
return shiftPositionsInScript(block.scriptText, targetSelector, delta);
736+
}
737+
case "scale-positions": {
738+
const { targetSelector, oldStart, oldDuration, newStart, newDuration } = body;
739+
if (!targetSelector || oldDuration <= 0 || newDuration <= 0) return block.scriptText;
740+
if (oldStart === newStart && oldDuration === newDuration) return block.scriptText;
741+
const { scalePositionsInScript } = parser;
742+
return scalePositionsInScript(
743+
block.scriptText,
744+
targetSelector,
745+
oldStart,
746+
oldDuration,
747+
newStart,
748+
newDuration,
749+
);
750+
}
718751
default:
719752
return respond({ error: `unknown mutation type: ${(body as { type: string }).type}` }, 400);
720753
}

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ interface GestureTrailOverlayProps {
66
sampleCount?: number;
77
trail?: Array<{ x: number; y: number }>;
88
simplifiedPoints?: Map<number, Record<string, number>>;
9-
canvasRect: { left: number; top: number; width: number; height: number };
9+
canvasRect: { left: number; top: number; width: number; height: number } | null;
1010
compositionSize?: { width: number; height: number };
1111
mode: "recording" | "preview";
1212
accentColor?: string;
@@ -22,6 +22,8 @@ export const GestureTrailOverlay = memo(function GestureTrailOverlay({
2222
mode,
2323
accentColor = "#3CE6AC",
2424
}: GestureTrailOverlayProps) {
25+
if (!canvasRect) return null;
26+
2527
const trailPoints = useMemo(() => {
2628
if (trail && trail.length > 1) {
2729
return trail.map((p) => `${p.x - canvasRect.left},${p.y - canvasRect.top}`).join(" ");

packages/studio/src/hooks/timelineEditingHelpers.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,5 +144,66 @@ export async function readFileContent(projectId: string, targetPath: string): Pr
144144
return data.content;
145145
}
146146

147+
/**
148+
* Shift all GSAP animation positions targeting a given element by a time delta.
149+
* Calls the server-side GSAP mutation endpoint which uses the AST-based parser.
150+
*/
151+
export async function shiftGsapPositions(
152+
projectId: string,
153+
filePath: string,
154+
elementId: string,
155+
delta: number,
156+
): Promise<void> {
157+
if (delta === 0 || !elementId) return;
158+
const res = await fetch(
159+
`/api/projects/${projectId}/gsap-mutations/${encodeURIComponent(filePath)}`,
160+
{
161+
method: "POST",
162+
headers: { "Content-Type": "application/json" },
163+
body: JSON.stringify({
164+
type: "shift-positions",
165+
targetSelector: `#${elementId}`,
166+
delta,
167+
}),
168+
},
169+
);
170+
if (!res.ok) {
171+
const err = await res.json().catch(() => null);
172+
throw new Error((err as { error?: string })?.error ?? "shift-positions failed");
173+
}
174+
}
175+
176+
export async function scaleGsapPositions(
177+
projectId: string,
178+
filePath: string,
179+
elementId: string,
180+
oldStart: number,
181+
oldDuration: number,
182+
newStart: number,
183+
newDuration: number,
184+
): Promise<void> {
185+
if (!elementId || oldDuration <= 0 || newDuration <= 0) return;
186+
if (oldStart === newStart && oldDuration === newDuration) return;
187+
const res = await fetch(
188+
`/api/projects/${projectId}/gsap-mutations/${encodeURIComponent(filePath)}`,
189+
{
190+
method: "POST",
191+
headers: { "Content-Type": "application/json" },
192+
body: JSON.stringify({
193+
type: "scale-positions",
194+
targetSelector: `#${elementId}`,
195+
oldStart,
196+
oldDuration,
197+
newStart,
198+
newDuration,
199+
}),
200+
},
201+
);
202+
if (!res.ok) {
203+
const err = await res.json().catch(() => null);
204+
throw new Error((err as { error?: string })?.error ?? "scale-positions failed");
205+
}
206+
}
207+
147208
// Re-export applyPatchByTarget for use in the hook (avoids double import in callers)
148209
export { applyPatchByTarget, formatTimelineAttributeNumber };

0 commit comments

Comments
 (0)