Skip to content

Commit f9e4fc0

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 f9e4fc0

8 files changed

Lines changed: 280 additions & 14 deletions

File tree

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

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
addAnimationWithKeyframesToScript,
2020
splitAnimationsInScript,
2121
splitIntoPropertyGroups,
22+
shiftPositionsInScript,
2223
} from "./gsapParser.js";
2324
import type { GsapAnimation } from "./gsapParser.js";
2425
import { classifyPropertyGroup, classifyTweenPropertyGroup } from "./gsapConstants.js";
@@ -2275,3 +2276,57 @@ describe("splitIntoPropertyGroups", () => {
22752276
}
22762277
});
22772278
});
2279+
2280+
describe("shiftPositionsInScript", () => {
2281+
it("shifts all numeric positions for the target selector", () => {
2282+
const script = `const tl = gsap.timeline({ paused: true });
2283+
tl.from("#hero", { opacity: 0, duration: 1 }, 0);
2284+
tl.to("#hero", { opacity: 0, duration: 0.5 }, 2.5);
2285+
tl.from("#bg", { scale: 0, duration: 1 }, 1);`;
2286+
const result = shiftPositionsInScript(script, "#hero", 3);
2287+
const parsed = parseGsapScript(result);
2288+
const hero = parsed.animations.filter((a) => a.targetSelector === "#hero");
2289+
expect(hero[0].position).toBe(3);
2290+
expect(hero[1].position).toBe(5.5);
2291+
const bg = parsed.animations.find((a) => a.targetSelector === "#bg");
2292+
expect(bg!.position).toBe(1);
2293+
});
2294+
2295+
it("clamps negative-going positions to zero", () => {
2296+
const script = `const tl = gsap.timeline({ paused: true });
2297+
tl.to("#el", { x: 100, duration: 1 }, 0.3);
2298+
tl.to("#el", { y: 50, duration: 1 }, 1.5);`;
2299+
const result = shiftPositionsInScript(script, "#el", -1.0);
2300+
const parsed = parseGsapScript(result);
2301+
const anims = parsed.animations.filter((a) => a.targetSelector === "#el");
2302+
expect(anims[0].position).toBe(0);
2303+
expect(anims[1].position).toBe(0.5);
2304+
});
2305+
2306+
it("returns the original script when delta is zero", () => {
2307+
const script = `const tl = gsap.timeline({ paused: true });
2308+
tl.to("#el", { x: 100, duration: 1 }, 2);`;
2309+
expect(shiftPositionsInScript(script, "#el", 0)).toBe(script);
2310+
});
2311+
2312+
it("does not collide when two tweens have adjacent positions (Via's race case)", () => {
2313+
const script = `const tl = gsap.timeline({ paused: true });
2314+
tl.to("#burst", { opacity: 1, duration: 0.5 }, 1.0);
2315+
tl.to("#burst", { opacity: 0, duration: 0.5 }, 1.5);`;
2316+
const result = shiftPositionsInScript(script, "#burst", 0.5);
2317+
const parsed = parseGsapScript(result);
2318+
const burst = parsed.animations.filter((a) => a.targetSelector === "#burst");
2319+
expect(burst[0].position).toBe(1.5);
2320+
expect(burst[1].position).toBe(2);
2321+
});
2322+
2323+
it("skips string positions", () => {
2324+
const script = `const tl = gsap.timeline({ paused: true });
2325+
tl.to("#el", { x: 100, duration: 1 }, 2);
2326+
tl.to("#el", { y: 50, duration: 1 }, "+=0.5");`;
2327+
const result = shiftPositionsInScript(script, "#el", 1);
2328+
const parsed = parseGsapScript(result);
2329+
expect(parsed.animations[0].position).toBe(3);
2330+
expect(parsed.animations[1].position).toBe("+=0.5");
2331+
});
2332+
});

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 };

packages/studio/src/hooks/useTimelineEditing.ts

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ import {
2626
readFileContent,
2727
applyPatchByTarget,
2828
formatTimelineAttributeNumber,
29+
shiftGsapPositions,
30+
scaleGsapPositions,
2931
} from "./timelineEditingHelpers";
3032
import type { PersistTimelineEditInput } from "./timelineEditingHelpers";
3133

@@ -122,6 +124,8 @@ export function useTimelineEditing({
122124
["data-start", formatTimelineAttributeNumber(updates.start)],
123125
["data-track-index", String(updates.track)],
124126
]);
127+
const delta = updates.start - element.start;
128+
const filePath = element.sourceFile || activeCompPath || "index.html";
125129
return enqueueEdit(element, "Move timeline clip", (original, target) => {
126130
let patched = applyPatchByTarget(original, target, {
127131
type: "attribute",
@@ -133,9 +137,16 @@ export function useTimelineEditing({
133137
property: "track-index",
134138
value: String(updates.track),
135139
});
140+
}).then(() => {
141+
const pid = projectIdRef.current;
142+
if (delta !== 0 && element.domId && pid) {
143+
return shiftGsapPositions(pid, filePath, element.domId, delta)
144+
.then(() => reloadPreview())
145+
.catch((err) => console.error("[Timeline] Failed to shift GSAP positions", err));
146+
}
136147
});
137148
},
138-
[previewIframeRef, enqueueEdit],
149+
[previewIframeRef, enqueueEdit, activeCompPath, reloadPreview],
139150
);
140151

141152
const handleTimelineElementResize = useCallback(
@@ -147,9 +158,6 @@ export function useTimelineEditing({
147158
["data-start", formatTimelineAttributeNumber(updates.start)],
148159
["data-duration", formatTimelineAttributeNumber(updates.duration)],
149160
];
150-
// A start-edge trim advances the media-start offset (skips into the
151-
// source). Patch it live too — otherwise the iframe keeps the old offset
152-
// and the clip only repositions instead of trimming the audio.
153161
if (updates.playbackStart != null) {
154162
const liveAttr =
155163
element.playbackStartAttr === "playback-start"
@@ -158,6 +166,9 @@ export function useTimelineEditing({
158166
liveAttrs.push([liveAttr, formatTimelineAttributeNumber(updates.playbackStart)]);
159167
}
160168
patchIframeDomTiming(previewIframeRef.current, element, liveAttrs);
169+
const filePath = element.sourceFile || activeCompPath || "index.html";
170+
const timingChanged =
171+
updates.start !== element.start || updates.duration !== element.duration;
161172
return enqueueEdit(element, "Resize timeline clip", (original, target) => {
162173
const pbs = resolveResizePlaybackStart(original, target, element, updates);
163174
let patched = applyPatchByTarget(original, target, {
@@ -178,9 +189,25 @@ export function useTimelineEditing({
178189
});
179190
}
180191
return patched;
192+
}).then(() => {
193+
const pid = projectIdRef.current;
194+
if (timingChanged && element.domId && pid) {
195+
return scaleGsapPositions(
196+
pid,
197+
filePath,
198+
element.domId,
199+
element.start,
200+
element.duration,
201+
updates.start,
202+
updates.duration,
203+
)
204+
.then(() => reloadPreview())
205+
.catch((err) => console.error("[Timeline] Failed to scale GSAP positions", err));
206+
}
207+
return reloadPreview();
181208
});
182209
},
183-
[previewIframeRef, enqueueEdit],
210+
[previewIframeRef, enqueueEdit, activeCompPath, reloadPreview],
184211
);
185212

186213
const handleTimelineElementDelete = useCallback(

0 commit comments

Comments
 (0)