feat(studio): scale GSAP positions on clip resize + shift on drag + diamond fixes (#1448)

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.
This commit is contained in:
Miguel Ángel
2026-06-15 02:31:35 -04:00
committed by GitHub
parent abaf67176c
commit 11b050de9a
8 changed files with 343 additions and 15 deletions
@@ -19,6 +19,8 @@ import {
addAnimationWithKeyframesToScript,
splitAnimationsInScript,
splitIntoPropertyGroups,
shiftPositionsInScript,
scalePositionsInScript,
} from "./gsapParser.js";
import type { GsapAnimation } from "./gsapParser.js";
import { classifyPropertyGroup, classifyTweenPropertyGroup } from "./gsapConstants.js";
@@ -2275,3 +2277,113 @@ describe("splitIntoPropertyGroups", () => {
}
});
});
describe("shiftPositionsInScript", () => {
it("shifts all numeric positions for the target selector", () => {
const script = `const tl = gsap.timeline({ paused: true });
tl.from("#hero", { opacity: 0, duration: 1 }, 0);
tl.to("#hero", { opacity: 0, duration: 0.5 }, 2.5);
tl.from("#bg", { scale: 0, duration: 1 }, 1);`;
const result = shiftPositionsInScript(script, "#hero", 3);
const parsed = parseGsapScript(result);
const hero = parsed.animations.filter((a) => a.targetSelector === "#hero");
expect(hero[0].position).toBe(3);
expect(hero[1].position).toBe(5.5);
const bg = parsed.animations.find((a) => a.targetSelector === "#bg");
expect(bg!.position).toBe(1);
});
it("clamps negative-going positions to zero", () => {
const script = `const tl = gsap.timeline({ paused: true });
tl.to("#el", { x: 100, duration: 1 }, 0.3);
tl.to("#el", { y: 50, duration: 1 }, 1.5);`;
const result = shiftPositionsInScript(script, "#el", -1.0);
const parsed = parseGsapScript(result);
const anims = parsed.animations.filter((a) => a.targetSelector === "#el");
expect(anims[0].position).toBe(0);
expect(anims[1].position).toBe(0.5);
});
it("returns the original script when delta is zero", () => {
const script = `const tl = gsap.timeline({ paused: true });
tl.to("#el", { x: 100, duration: 1 }, 2);`;
expect(shiftPositionsInScript(script, "#el", 0)).toBe(script);
});
it("does not collide when two tweens have adjacent positions", () => {
const script = `const tl = gsap.timeline({ paused: true });
tl.to("#burst", { opacity: 1, duration: 0.5 }, 1.0);
tl.to("#burst", { opacity: 0, duration: 0.5 }, 1.5);`;
const result = shiftPositionsInScript(script, "#burst", 0.5);
const parsed = parseGsapScript(result);
const burst = parsed.animations.filter((a) => a.targetSelector === "#burst");
expect(burst[0].position).toBe(1.5);
expect(burst[1].position).toBe(2);
});
it("skips string positions", () => {
const script = `const tl = gsap.timeline({ paused: true });
tl.to("#el", { x: 100, duration: 1 }, 2);
tl.to("#el", { y: 50, duration: 1 }, "+=0.5");`;
const result = shiftPositionsInScript(script, "#el", 1);
const parsed = parseGsapScript(result);
expect(parsed.animations[0].position).toBe(3);
expect(parsed.animations[1].position).toBe("+=0.5");
});
});
describe("scalePositionsInScript", () => {
it("scales positions and durations proportionally for the target selector", () => {
const script = `const tl = gsap.timeline({ paused: true });
tl.from("#hero", { opacity: 0, duration: 1 }, 0);
tl.to("#hero", { opacity: 0, duration: 0.5 }, 2.5);
tl.from("#bg", { scale: 0, duration: 1 }, 1);`;
const result = scalePositionsInScript(script, "#hero", 0, 3, 0, 2);
const parsed = parseGsapScript(result);
const hero = parsed.animations.filter((a) => a.targetSelector === "#hero");
expect(hero[0].position).toBe(0);
expect(hero[0].duration).toBeCloseTo(0.667, 2);
expect(hero[1].position).toBeCloseTo(1.667, 2);
expect(hero[1].duration).toBeCloseTo(0.333, 2);
const bg = parsed.animations.find((a) => a.targetSelector === "#bg");
expect(bg!.position).toBe(1);
expect(bg!.duration).toBe(1);
});
it("handles start-edge resize (new start + shorter duration)", () => {
const script = `const tl = gsap.timeline({ paused: true });
tl.from("#el", { opacity: 0, duration: 1 }, 0);
tl.to("#el", { y: 50, duration: 0.5 }, 2.5);`;
const result = scalePositionsInScript(script, "#el", 0, 3, 1, 2);
const parsed = parseGsapScript(result);
const anims = parsed.animations.filter((a) => a.targetSelector === "#el");
expect(anims[0].position).toBe(1);
expect(anims[0].duration).toBeCloseTo(0.667, 2);
expect(anims[1].position).toBeCloseTo(2.667, 2);
expect(anims[1].duration).toBeCloseTo(0.333, 2);
});
it("clamps negative-going positions to zero", () => {
const script = `const tl = gsap.timeline({ paused: true });
tl.to("#el", { x: 100, duration: 1 }, 2);`;
const result = scalePositionsInScript(script, "#el", 2, 1, 0, 0.5);
const parsed = parseGsapScript(result);
expect(parsed.animations[0].position).toBe(0);
});
it("returns the original script when old and new timing are identical", () => {
const script = `const tl = gsap.timeline({ paused: true });
tl.to("#el", { x: 100, duration: 1 }, 2);`;
expect(scalePositionsInScript(script, "#el", 0, 3, 0, 3)).toBe(script);
});
it("skips string positions", () => {
const script = `const tl = gsap.timeline({ paused: true });
tl.to("#el", { x: 100, duration: 1 }, 2);
tl.to("#el", { y: 50, duration: 1 }, "+=0.5");`;
const result = scalePositionsInScript(script, "#el", 0, 3, 0, 2);
const parsed = parseGsapScript(result);
expect(parsed.animations[0].position).toBeCloseTo(1.333, 2);
expect(parsed.animations[1].position).toBe("+=0.5");
});
});
+61
View File
@@ -1322,6 +1322,67 @@ export function updateAnimationInScript(
return recast.print(parsed.ast).code;
}
export function shiftPositionsInScript(
script: string,
targetSelector: string,
delta: number,
): string {
let parsed: ParsedGsapAst;
try {
parsed = parseGsapAst(script);
} catch (e) {
console.warn("[gsap-parser] shiftPositionsInScript parse failed:", e);
return script;
}
let changed = false;
for (const entry of parsed.located) {
if (entry.animation.targetSelector !== targetSelector) continue;
if (typeof entry.animation.position !== "number") continue;
const newPos = Math.max(0, Math.round((entry.animation.position + delta) * 1000) / 1000);
applyUpdatesToCall(entry.call, { position: newPos });
changed = true;
}
return changed ? recast.print(parsed.ast).code : script;
}
export function scalePositionsInScript(
script: string,
targetSelector: string,
oldStart: number,
oldDuration: number,
newStart: number,
newDuration: number,
): string {
if (oldDuration <= 0 || newDuration <= 0) return script;
const ratio = newDuration / oldDuration;
let parsed: ParsedGsapAst;
try {
parsed = parseGsapAst(script);
} catch (e) {
console.warn("[gsap-parser] scalePositionsInScript parse failed:", e);
return script;
}
let changed = false;
for (const entry of parsed.located) {
if (entry.animation.targetSelector !== targetSelector) continue;
if (typeof entry.animation.position !== "number") continue;
const newPos = Math.max(
0,
Math.round((newStart + (entry.animation.position - oldStart) * ratio) * 1000) / 1000,
);
const updates: Partial<GsapAnimation> = { position: newPos };
if (typeof entry.animation.duration === "number" && entry.animation.duration > 0) {
updates.duration = Math.max(
0.001,
Math.round(entry.animation.duration * ratio * 1000) / 1000,
);
}
applyUpdatesToCall(entry.call, updates);
changed = true;
}
return changed ? recast.print(parsed.ast).code : script;
}
function updateAnimationSelector(script: string, animationId: string, newSelector: string): string {
let parsed: ParsedGsapAst;
try {
@@ -466,6 +466,19 @@ type GsapMutationRequest =
| {
type: "delete-all-for-selector";
targetSelector: string;
}
| {
type: "shift-positions";
targetSelector: string;
delta: number;
}
| {
type: "scale-positions";
targetSelector: string;
oldStart: number;
oldDuration: number;
newStart: number;
newDuration: number;
};
// ── GSAP mutation executor ──────────────────────────────────────────────────
@@ -715,6 +728,35 @@ async function executeGsapMutation(
const result = splitIntoPropertyGroups(block.scriptText, body.animationId);
return result.script;
}
case "shift-positions": {
const { targetSelector, delta } = body;
if (!targetSelector || !Number.isFinite(delta) || delta === 0) return block.scriptText;
const { shiftPositionsInScript } = parser;
return shiftPositionsInScript(block.scriptText, targetSelector, delta);
}
case "scale-positions": {
const { targetSelector, oldStart, oldDuration, newStart, newDuration } = body;
if (
!targetSelector ||
!Number.isFinite(oldStart) ||
!Number.isFinite(oldDuration) ||
!Number.isFinite(newStart) ||
!Number.isFinite(newDuration) ||
oldDuration <= 0 ||
newDuration <= 0
)
return block.scriptText;
if (oldStart === newStart && oldDuration === newDuration) return block.scriptText;
const { scalePositionsInScript } = parser;
return scalePositionsInScript(
block.scriptText,
targetSelector,
oldStart,
oldDuration,
newStart,
newDuration,
);
}
default:
return respond({ error: `unknown mutation type: ${(body as { type: string }).type}` }, 400);
}