diff --git a/packages/core/src/parsers/gsapParser.test.ts b/packages/core/src/parsers/gsapParser.test.ts index 0ef8797b6..7f56fd0a7 100644 --- a/packages/core/src/parsers/gsapParser.test.ts +++ b/packages/core/src/parsers/gsapParser.test.ts @@ -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"); + }); +}); diff --git a/packages/core/src/parsers/gsapParser.ts b/packages/core/src/parsers/gsapParser.ts index 8495a3b2c..e0e9498de 100644 --- a/packages/core/src/parsers/gsapParser.ts +++ b/packages/core/src/parsers/gsapParser.ts @@ -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 = { 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 { diff --git a/packages/core/src/studio-api/routes/files.ts b/packages/core/src/studio-api/routes/files.ts index 0f5e2a0fc..9a7642666 100644 --- a/packages/core/src/studio-api/routes/files.ts +++ b/packages/core/src/studio-api/routes/files.ts @@ -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); } diff --git a/packages/studio/src/components/editor/GestureTrailOverlay.tsx b/packages/studio/src/components/editor/GestureTrailOverlay.tsx index bd1c97e69..c02193b21 100644 --- a/packages/studio/src/components/editor/GestureTrailOverlay.tsx +++ b/packages/studio/src/components/editor/GestureTrailOverlay.tsx @@ -6,7 +6,7 @@ interface GestureTrailOverlayProps { sampleCount?: number; trail?: Array<{ x: number; y: number }>; simplifiedPoints?: Map>; - canvasRect: { left: number; top: number; width: number; height: number }; + canvasRect: { left: number; top: number; width: number; height: number } | null; compositionSize?: { width: number; height: number }; mode: "recording" | "preview"; accentColor?: string; @@ -23,6 +23,7 @@ export const GestureTrailOverlay = memo(function GestureTrailOverlay({ accentColor = "#3CE6AC", }: GestureTrailOverlayProps) { const trailPoints = useMemo(() => { + if (!canvasRect) return ""; if (trail && trail.length > 1) { return trail.map((p) => `${p.x - canvasRect.left},${p.y - canvasRect.top}`).join(" "); } @@ -32,7 +33,7 @@ export const GestureTrailOverlay = memo(function GestureTrailOverlay({ .map((s) => `${s.properties.x},${s.properties.y}`) .join(" "); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [samples, trail, sampleCount, canvasRect.left, canvasRect.top]); + }, [samples, trail, sampleCount, canvasRect?.left, canvasRect?.top]); const simplifiedPath = useMemo(() => { if (!simplifiedPoints || simplifiedPoints.size === 0) return ""; @@ -58,7 +59,7 @@ export const GestureTrailOverlay = memo(function GestureTrailOverlay({ return pts.sort((a, b) => a.pct - b.pct); }, [simplifiedPoints]); - if (samples.length < 2 && !simplifiedPoints) return null; + if (!canvasRect || (samples.length < 2 && !simplifiedPoints)) return null; return ( { + 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 { + 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 }; diff --git a/packages/studio/src/hooks/useTimelineEditing.ts b/packages/studio/src/hooks/useTimelineEditing.ts index 096c0ea96..bc8bbf8c2 100644 --- a/packages/studio/src/hooks/useTimelineEditing.ts +++ b/packages/studio/src/hooks/useTimelineEditing.ts @@ -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( diff --git a/packages/studio/src/player/components/TimelineClipDiamonds.tsx b/packages/studio/src/player/components/TimelineClipDiamonds.tsx index 6f946ed9d..361b975fe 100644 --- a/packages/studio/src/player/components/TimelineClipDiamonds.tsx +++ b/packages/studio/src/player/components/TimelineClipDiamonds.tsx @@ -40,6 +40,15 @@ interface TimelineClipDiamondsProps { } const DIAMOND_RATIO = 0.8; +// Percentage tolerance for rendering keyframes near clip boundaries. Keyframes +// slightly outside [0, 100] (from rounding or stale cache during the async +// persist → reload cycle) are clamped to the clip edge rather than hidden. +export const KF_MIN_PCT = -5; +export const KF_MAX_PCT = 105; + +function clampDiamondLeft(rawLeft: number, diamondSize: number, clipWidth: number): number { + return Math.max(0, Math.min(clipWidth - diamondSize, rawLeft)); +} export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({ keyframesData, @@ -108,7 +117,9 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({ const diamondSize = Math.round(clipHeightPx * (beatsActive ? 0.45 : DIAMOND_RATIO)); const half = diamondSize / 2; const centerY = beatsActive ? BEAT_BAND_H + (clipHeightPx - BEAT_BAND_H) / 2 : clipHeightPx / 2; - const sorted = keyframesData.keyframes.slice().sort((a, b) => a.percentage - b.percentage); + const sorted = keyframesData.keyframes + .filter((kf) => kf.percentage >= KF_MIN_PCT && kf.percentage <= KF_MAX_PCT) + .sort((a, b) => a.percentage - b.percentage); const baseColor = isSelected ? accentColor : "#a3a3a3"; const baseOpacity = isSelected ? 0.4 : 0.25; @@ -182,7 +193,6 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({ document.addEventListener("pointerup", handleUp); }; - // Effective % for rendering: the dragged keyframe follows the (snapped) cursor. const effPct = (p: number): number => (drag && drag.origPct === p ? drag.pct : p); return ( @@ -190,8 +200,12 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({ {sorted.map((kf, i) => { if (i === 0) return null; const prev = sorted[i - 1]!; - const x1 = (effPct(prev.percentage) / 100) * clipWidthPx; - const x2 = (effPct(kf.percentage) / 100) * clipWidthPx; + const x1 = Math.max( + 0, + Math.min(clipWidthPx, (effPct(prev.percentage) / 100) * clipWidthPx), + ); + const x2 = Math.max(0, Math.min(clipWidthPx, (effPct(kf.percentage) / 100) * clipWidthPx)); + if (x2 - x1 < 1) return null; return (
{ - const leftPx = (effPct(kf.percentage) / 100) * clipWidthPx - half; + const leftPx = clampDiamondLeft( + (effPct(kf.percentage) / 100) * clipWidthPx - half, + diamondSize, + clipWidthPx, + ); const kfKey = `${elementId}:${kf.percentage}`; const isKfSelected = selectedKeyframes.has(kfKey); const atPlayhead = isSelected && Math.abs(kf.percentage - currentPercentage) < 0.5; diff --git a/packages/studio/src/player/components/TimelinePropertyRows.tsx b/packages/studio/src/player/components/TimelinePropertyRows.tsx index dc16be86d..a87e75a8f 100644 --- a/packages/studio/src/player/components/TimelinePropertyRows.tsx +++ b/packages/studio/src/player/components/TimelinePropertyRows.tsx @@ -1,5 +1,6 @@ import { memo } from "react"; import type { KeyframeCacheEntry } from "../store/playerStore"; +import { KF_MIN_PCT, KF_MAX_PCT } from "./TimelineClipDiamonds"; const SUB_TRACK_H = 24; const DIAMOND_SIZE = 6; @@ -44,7 +45,9 @@ export const TimelinePropertyRows = memo(function TimelinePropertyRows({ return (
{properties.map((prop) => { - const propKeyframes = keyframesData.keyframes.filter((kf) => prop in kf.properties); + const propKeyframes = keyframesData.keyframes + .filter((kf) => prop in kf.properties) + .filter((kf) => kf.percentage >= KF_MIN_PCT && kf.percentage <= KF_MAX_PCT); if (propKeyframes.length === 0) return null; return ( @@ -67,7 +70,10 @@ export const TimelinePropertyRows = memo(function TimelinePropertyRows({ strokeWidth={1} /> {propKeyframes.map((kf) => { - const x = (kf.percentage / 100) * clipWidthPx; + const x = Math.max( + HALF, + Math.min(clipWidthPx - HALF, (kf.percentage / 100) * clipWidthPx), + ); const y = SUB_TRACK_H / 2; const key = `${elementId}:${kf.percentage}`; const isKfSelected = selectedKeyframes.has(key);