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 {