Files
hyperframes/packages/studio/src/components/editor/GestureTrailOverlay.tsx
T
Miguel Ángel 11b050de9a 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.
2026-06-15 02:31:35 -04:00

134 lines
4.0 KiB
TypeScript

import { memo, useMemo } from "react";
import type { GestureSample } from "../../hooks/useGestureRecording";
interface GestureTrailOverlayProps {
samples: GestureSample[];
sampleCount?: number;
trail?: Array<{ x: number; y: number }>;
simplifiedPoints?: Map<number, Record<string, number>>;
canvasRect: { left: number; top: number; width: number; height: number } | null;
compositionSize?: { width: number; height: number };
mode: "recording" | "preview";
accentColor?: string;
}
export const GestureTrailOverlay = memo(function GestureTrailOverlay({
samples,
sampleCount,
trail,
simplifiedPoints,
canvasRect,
compositionSize,
mode,
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(" ");
}
if (samples.length === 0) return "";
return samples
.filter((s) => s.properties.x != null && s.properties.y != null)
.map((s) => `${s.properties.x},${s.properties.y}`)
.join(" ");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [samples, trail, sampleCount, canvasRect?.left, canvasRect?.top]);
const simplifiedPath = useMemo(() => {
if (!simplifiedPoints || simplifiedPoints.size === 0) return "";
const pts: Array<{ x: number; y: number; pct: number }> = [];
for (const [pct, props] of simplifiedPoints) {
if (props.x != null && props.y != null) {
pts.push({ x: props.x, y: props.y, pct });
}
}
pts.sort((a, b) => a.pct - b.pct);
if (pts.length === 0) return "";
return pts.map((p) => `${p.x},${p.y}`).join(" ");
}, [simplifiedPoints]);
const diamondPositions = useMemo(() => {
if (!simplifiedPoints || simplifiedPoints.size === 0) return [];
const pts: Array<{ x: number; y: number; pct: number }> = [];
for (const [pct, props] of simplifiedPoints) {
if (props.x != null && props.y != null) {
pts.push({ x: props.x, y: props.y, pct });
}
}
return pts.sort((a, b) => a.pct - b.pct);
}, [simplifiedPoints]);
if (!canvasRect || (samples.length < 2 && !simplifiedPoints)) return null;
return (
<svg
className="pointer-events-none fixed z-50"
style={{
left: canvasRect.left,
top: canvasRect.top,
width: canvasRect.width,
height: canvasRect.height,
}}
viewBox={
trail && trail.length > 1
? `0 0 ${canvasRect.width} ${canvasRect.height}`
: `0 0 ${compositionSize?.width ?? canvasRect.width} ${compositionSize?.height ?? canvasRect.height}`
}
>
{mode === "recording" && trailPoints && (
<polyline
points={trailPoints}
fill="none"
stroke={accentColor}
strokeWidth="2"
strokeOpacity="0.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
)}
{mode === "preview" && (
<>
{trailPoints && (
<polyline
points={trailPoints}
fill="none"
stroke={accentColor}
strokeWidth="1"
strokeOpacity="0.2"
strokeDasharray="4 3"
strokeLinecap="round"
/>
)}
{simplifiedPath && (
<polyline
points={simplifiedPath}
fill="none"
stroke={accentColor}
strokeWidth="2"
strokeOpacity="0.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
)}
{diamondPositions.map((pt) => (
<g key={pt.pct} transform={`translate(${pt.x}, ${pt.y})`}>
<rect
x="-4"
y="-4"
width="8"
height="8"
rx="1"
transform="rotate(45)"
fill={accentColor}
fillOpacity="0.9"
/>
</g>
))}
</>
)}
</svg>
);
});