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
@@ -144,5 +144,66 @@ export async function readFileContent(projectId: string, targetPath: string): Pr
return data.content;
}
/**
* Shift all GSAP animation positions targeting a given element by a time delta.
* Calls the server-side GSAP mutation endpoint which uses the AST-based parser.
*/
export async function shiftGsapPositions(
projectId: string,
filePath: string,
elementId: string,
delta: number,
): Promise<void> {
if (delta === 0 || !elementId) return;
const res = await fetch(
`/api/projects/${projectId}/gsap-mutations/${encodeURIComponent(filePath)}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "shift-positions",
targetSelector: `#${elementId}`,
delta,
}),
},
);
if (!res.ok) {
const err = await res.json().catch(() => null);
throw new Error((err as { error?: string })?.error ?? "shift-positions failed");
}
}
export async function scaleGsapPositions(
projectId: string,
filePath: string,
elementId: string,
oldStart: number,
oldDuration: number,
newStart: number,
newDuration: number,
): Promise<void> {
if (!elementId || oldDuration <= 0 || newDuration <= 0) return;
if (oldStart === newStart && oldDuration === newDuration) return;
const res = await fetch(
`/api/projects/${projectId}/gsap-mutations/${encodeURIComponent(filePath)}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "scale-positions",
targetSelector: `#${elementId}`,
oldStart,
oldDuration,
newStart,
newDuration,
}),
},
);
if (!res.ok) {
const err = await res.json().catch(() => null);
throw new Error((err as { error?: string })?.error ?? "scale-positions failed");
}
}
// Re-export applyPatchByTarget for use in the hook (avoids double import in callers)
export { applyPatchByTarget, formatTimelineAttributeNumber };
@@ -26,6 +26,8 @@ import {
readFileContent,
applyPatchByTarget,
formatTimelineAttributeNumber,
shiftGsapPositions,
scaleGsapPositions,
} from "./timelineEditingHelpers";
import type { PersistTimelineEditInput } from "./timelineEditingHelpers";
@@ -122,6 +124,8 @@ export function useTimelineEditing({
["data-start", formatTimelineAttributeNumber(updates.start)],
["data-track-index", String(updates.track)],
]);
const delta = updates.start - element.start;
const filePath = element.sourceFile || activeCompPath || "index.html";
return enqueueEdit(element, "Move timeline clip", (original, target) => {
let patched = applyPatchByTarget(original, target, {
type: "attribute",
@@ -133,9 +137,16 @@ export function useTimelineEditing({
property: "track-index",
value: String(updates.track),
});
}).then(() => {
const pid = projectIdRef.current;
if (delta !== 0 && element.domId && pid) {
return shiftGsapPositions(pid, filePath, element.domId, delta)
.then(() => reloadPreview())
.catch((err) => console.error("[Timeline] Failed to shift GSAP positions", err));
}
});
},
[previewIframeRef, enqueueEdit],
[previewIframeRef, enqueueEdit, activeCompPath, reloadPreview],
);
const handleTimelineElementResize = useCallback(
@@ -147,9 +158,6 @@ export function useTimelineEditing({
["data-start", formatTimelineAttributeNumber(updates.start)],
["data-duration", formatTimelineAttributeNumber(updates.duration)],
];
// A start-edge trim advances the media-start offset (skips into the
// source). Patch it live too — otherwise the iframe keeps the old offset
// and the clip only repositions instead of trimming the audio.
if (updates.playbackStart != null) {
const liveAttr =
element.playbackStartAttr === "playback-start"
@@ -158,6 +166,9 @@ export function useTimelineEditing({
liveAttrs.push([liveAttr, formatTimelineAttributeNumber(updates.playbackStart)]);
}
patchIframeDomTiming(previewIframeRef.current, element, liveAttrs);
const filePath = element.sourceFile || activeCompPath || "index.html";
const timingChanged =
updates.start !== element.start || updates.duration !== element.duration;
return enqueueEdit(element, "Resize timeline clip", (original, target) => {
const pbs = resolveResizePlaybackStart(original, target, element, updates);
let patched = applyPatchByTarget(original, target, {
@@ -178,9 +189,25 @@ export function useTimelineEditing({
});
}
return patched;
}).then(() => {
const pid = projectIdRef.current;
if (timingChanged && element.domId && pid) {
return scaleGsapPositions(
pid,
filePath,
element.domId,
element.start,
element.duration,
updates.start,
updates.duration,
)
.then(() => reloadPreview())
.catch((err) => console.error("[Timeline] Failed to scale GSAP positions", err));
}
return reloadPreview();
});
},
[previewIframeRef, enqueueEdit],
[previewIframeRef, enqueueEdit, activeCompPath, reloadPreview],
);
const handleTimelineElementDelete = useCallback(