mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +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,
|
addAnimationWithKeyframesToScript,
|
||||||
splitAnimationsInScript,
|
splitAnimationsInScript,
|
||||||
splitIntoPropertyGroups,
|
splitIntoPropertyGroups,
|
||||||
|
shiftPositionsInScript,
|
||||||
|
scalePositionsInScript,
|
||||||
} from "./gsapParser.js";
|
} from "./gsapParser.js";
|
||||||
import type { GsapAnimation } from "./gsapParser.js";
|
import type { GsapAnimation } from "./gsapParser.js";
|
||||||
import { classifyPropertyGroup, classifyTweenPropertyGroup } from "./gsapConstants.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;
|
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 {
|
function updateAnimationSelector(script: string, animationId: string, newSelector: string): string {
|
||||||
let parsed: ParsedGsapAst;
|
let parsed: ParsedGsapAst;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -466,6 +466,19 @@ type GsapMutationRequest =
|
|||||||
| {
|
| {
|
||||||
type: "delete-all-for-selector";
|
type: "delete-all-for-selector";
|
||||||
targetSelector: string;
|
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 ──────────────────────────────────────────────────
|
// ── GSAP mutation executor ──────────────────────────────────────────────────
|
||||||
@@ -715,6 +728,35 @@ async function executeGsapMutation(
|
|||||||
const result = splitIntoPropertyGroups(block.scriptText, body.animationId);
|
const result = splitIntoPropertyGroups(block.scriptText, body.animationId);
|
||||||
return result.script;
|
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:
|
default:
|
||||||
return respond({ error: `unknown mutation type: ${(body as { type: string }).type}` }, 400);
|
return respond({ error: `unknown mutation type: ${(body as { type: string }).type}` }, 400);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ interface GestureTrailOverlayProps {
|
|||||||
sampleCount?: number;
|
sampleCount?: number;
|
||||||
trail?: Array<{ x: number; y: number }>;
|
trail?: Array<{ x: number; y: number }>;
|
||||||
simplifiedPoints?: Map<number, Record<string, number>>;
|
simplifiedPoints?: Map<number, Record<string, number>>;
|
||||||
canvasRect: { left: number; top: number; width: number; height: number };
|
canvasRect: { left: number; top: number; width: number; height: number } | null;
|
||||||
compositionSize?: { width: number; height: number };
|
compositionSize?: { width: number; height: number };
|
||||||
mode: "recording" | "preview";
|
mode: "recording" | "preview";
|
||||||
accentColor?: string;
|
accentColor?: string;
|
||||||
@@ -23,6 +23,7 @@ export const GestureTrailOverlay = memo(function GestureTrailOverlay({
|
|||||||
accentColor = "#3CE6AC",
|
accentColor = "#3CE6AC",
|
||||||
}: GestureTrailOverlayProps) {
|
}: GestureTrailOverlayProps) {
|
||||||
const trailPoints = useMemo(() => {
|
const trailPoints = useMemo(() => {
|
||||||
|
if (!canvasRect) return "";
|
||||||
if (trail && trail.length > 1) {
|
if (trail && trail.length > 1) {
|
||||||
return trail.map((p) => `${p.x - canvasRect.left},${p.y - canvasRect.top}`).join(" ");
|
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}`)
|
.map((s) => `${s.properties.x},${s.properties.y}`)
|
||||||
.join(" ");
|
.join(" ");
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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(() => {
|
const simplifiedPath = useMemo(() => {
|
||||||
if (!simplifiedPoints || simplifiedPoints.size === 0) return "";
|
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);
|
return pts.sort((a, b) => a.pct - b.pct);
|
||||||
}, [simplifiedPoints]);
|
}, [simplifiedPoints]);
|
||||||
|
|
||||||
if (samples.length < 2 && !simplifiedPoints) return null;
|
if (!canvasRect || (samples.length < 2 && !simplifiedPoints)) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
|
|||||||
@@ -144,5 +144,66 @@ export async function readFileContent(projectId: string, targetPath: string): Pr
|
|||||||
return data.content;
|
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)
|
// Re-export applyPatchByTarget for use in the hook (avoids double import in callers)
|
||||||
export { applyPatchByTarget, formatTimelineAttributeNumber };
|
export { applyPatchByTarget, formatTimelineAttributeNumber };
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ import {
|
|||||||
readFileContent,
|
readFileContent,
|
||||||
applyPatchByTarget,
|
applyPatchByTarget,
|
||||||
formatTimelineAttributeNumber,
|
formatTimelineAttributeNumber,
|
||||||
|
shiftGsapPositions,
|
||||||
|
scaleGsapPositions,
|
||||||
} from "./timelineEditingHelpers";
|
} from "./timelineEditingHelpers";
|
||||||
import type { PersistTimelineEditInput } from "./timelineEditingHelpers";
|
import type { PersistTimelineEditInput } from "./timelineEditingHelpers";
|
||||||
|
|
||||||
@@ -122,6 +124,8 @@ export function useTimelineEditing({
|
|||||||
["data-start", formatTimelineAttributeNumber(updates.start)],
|
["data-start", formatTimelineAttributeNumber(updates.start)],
|
||||||
["data-track-index", String(updates.track)],
|
["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) => {
|
return enqueueEdit(element, "Move timeline clip", (original, target) => {
|
||||||
let patched = applyPatchByTarget(original, target, {
|
let patched = applyPatchByTarget(original, target, {
|
||||||
type: "attribute",
|
type: "attribute",
|
||||||
@@ -133,9 +137,16 @@ export function useTimelineEditing({
|
|||||||
property: "track-index",
|
property: "track-index",
|
||||||
value: String(updates.track),
|
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(
|
const handleTimelineElementResize = useCallback(
|
||||||
@@ -147,9 +158,6 @@ export function useTimelineEditing({
|
|||||||
["data-start", formatTimelineAttributeNumber(updates.start)],
|
["data-start", formatTimelineAttributeNumber(updates.start)],
|
||||||
["data-duration", formatTimelineAttributeNumber(updates.duration)],
|
["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) {
|
if (updates.playbackStart != null) {
|
||||||
const liveAttr =
|
const liveAttr =
|
||||||
element.playbackStartAttr === "playback-start"
|
element.playbackStartAttr === "playback-start"
|
||||||
@@ -158,6 +166,9 @@ export function useTimelineEditing({
|
|||||||
liveAttrs.push([liveAttr, formatTimelineAttributeNumber(updates.playbackStart)]);
|
liveAttrs.push([liveAttr, formatTimelineAttributeNumber(updates.playbackStart)]);
|
||||||
}
|
}
|
||||||
patchIframeDomTiming(previewIframeRef.current, element, liveAttrs);
|
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) => {
|
return enqueueEdit(element, "Resize timeline clip", (original, target) => {
|
||||||
const pbs = resolveResizePlaybackStart(original, target, element, updates);
|
const pbs = resolveResizePlaybackStart(original, target, element, updates);
|
||||||
let patched = applyPatchByTarget(original, target, {
|
let patched = applyPatchByTarget(original, target, {
|
||||||
@@ -178,9 +189,25 @@ export function useTimelineEditing({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
return patched;
|
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(
|
const handleTimelineElementDelete = useCallback(
|
||||||
|
|||||||
@@ -40,6 +40,15 @@ interface TimelineClipDiamondsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DIAMOND_RATIO = 0.8;
|
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({
|
export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
||||||
keyframesData,
|
keyframesData,
|
||||||
@@ -108,7 +117,9 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
|||||||
const diamondSize = Math.round(clipHeightPx * (beatsActive ? 0.45 : DIAMOND_RATIO));
|
const diamondSize = Math.round(clipHeightPx * (beatsActive ? 0.45 : DIAMOND_RATIO));
|
||||||
const half = diamondSize / 2;
|
const half = diamondSize / 2;
|
||||||
const centerY = beatsActive ? BEAT_BAND_H + (clipHeightPx - BEAT_BAND_H) / 2 : clipHeightPx / 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 baseColor = isSelected ? accentColor : "#a3a3a3";
|
||||||
const baseOpacity = isSelected ? 0.4 : 0.25;
|
const baseOpacity = isSelected ? 0.4 : 0.25;
|
||||||
|
|
||||||
@@ -182,7 +193,6 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
|||||||
document.addEventListener("pointerup", handleUp);
|
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);
|
const effPct = (p: number): number => (drag && drag.origPct === p ? drag.pct : p);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -190,8 +200,12 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
|||||||
{sorted.map((kf, i) => {
|
{sorted.map((kf, i) => {
|
||||||
if (i === 0) return null;
|
if (i === 0) return null;
|
||||||
const prev = sorted[i - 1]!;
|
const prev = sorted[i - 1]!;
|
||||||
const x1 = (effPct(prev.percentage) / 100) * clipWidthPx;
|
const x1 = Math.max(
|
||||||
const x2 = (effPct(kf.percentage) / 100) * clipWidthPx;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`line-${i}-${prev.percentage}-${kf.percentage}`}
|
key={`line-${i}-${prev.percentage}-${kf.percentage}`}
|
||||||
@@ -211,7 +225,11 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
|||||||
})}
|
})}
|
||||||
|
|
||||||
{sorted.map((kf, i) => {
|
{sorted.map((kf, i) => {
|
||||||
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 kfKey = `${elementId}:${kf.percentage}`;
|
||||||
const isKfSelected = selectedKeyframes.has(kfKey);
|
const isKfSelected = selectedKeyframes.has(kfKey);
|
||||||
const atPlayhead = isSelected && Math.abs(kf.percentage - currentPercentage) < 0.5;
|
const atPlayhead = isSelected && Math.abs(kf.percentage - currentPercentage) < 0.5;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
import type { KeyframeCacheEntry } from "../store/playerStore";
|
import type { KeyframeCacheEntry } from "../store/playerStore";
|
||||||
|
import { KF_MIN_PCT, KF_MAX_PCT } from "./TimelineClipDiamonds";
|
||||||
|
|
||||||
const SUB_TRACK_H = 24;
|
const SUB_TRACK_H = 24;
|
||||||
const DIAMOND_SIZE = 6;
|
const DIAMOND_SIZE = 6;
|
||||||
@@ -44,7 +45,9 @@ export const TimelinePropertyRows = memo(function TimelinePropertyRows({
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
{properties.map((prop) => {
|
{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;
|
if (propKeyframes.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -67,7 +70,10 @@ export const TimelinePropertyRows = memo(function TimelinePropertyRows({
|
|||||||
strokeWidth={1}
|
strokeWidth={1}
|
||||||
/>
|
/>
|
||||||
{propKeyframes.map((kf) => {
|
{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 y = SUB_TRACK_H / 2;
|
||||||
const key = `${elementId}:${kf.percentage}`;
|
const key = `${elementId}:${kf.percentage}`;
|
||||||
const isKfSelected = selectedKeyframes.has(key);
|
const isKfSelected = selectedKeyframes.has(key);
|
||||||
|
|||||||
Reference in New Issue
Block a user