mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +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:
@@ -6,7 +6,7 @@ interface GestureTrailOverlayProps {
|
||||
sampleCount?: number;
|
||||
trail?: Array<{ x: number; y: 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 };
|
||||
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 (
|
||||
<svg
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
key={`line-${i}-${prev.percentage}-${kf.percentage}`}
|
||||
@@ -211,7 +225,11 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
||||
})}
|
||||
|
||||
{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 isKfSelected = selectedKeyframes.has(kfKey);
|
||||
const atPlayhead = isSelected && Math.abs(kf.percentage - currentPercentage) < 0.5;
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex flex-col">
|
||||
{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);
|
||||
|
||||
Reference in New Issue
Block a user