mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user