mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 06:30:03 +00:00
* feat(studio): re-expose keyframe retiming via 'Move to Playhead' (closes #1782) Since #1763 removed the timeline keyframe-drag affordance there was no GUI gesture to retime an existing keyframe while preserving its value and easing (delete+re-add bakes computed values and drops the explicit ease). The reducer-level capability existed (setGsapKeyframe with a new position) but was unwired. Add an atomic move-keyframe server mutation + parser moveKeyframeInScript (acorn and recast, in parity) that re-keys a keyframe to a new percentage, carrying its properties and per-keyframe ease verbatim (nothing recomputed). Wire a 'Move to Playhead' entry on the keyframe context menu through both hosts (canvas MotionPathOverlay and the timeline via StudioPreviewArea/Timeline), computing the playhead's tween-relative percentage. Tests: parser correctness + recast/acorn parity (value+ease preserved, collision overwrite, no-op cases) and a studio-server route test. Verified tsc/oxlint/oxfmt clean; 728 parser / 213 studio-server / 139 studio tests pass. Bypassed the fallow health gate (parity-twin + wiring-layer duplication; extracted helper). * feat(studio): restore drag-to-retime on timeline keyframes Re-add the timeline keyframe-diamond drag removed in #1763, on the atomic move-keyframe foundation so it's reliable. #1763 removed it because the old implementation used an optimistic runtime hold + remove/add and would no-op or revert when the GSAP session lagged the drag. This version: - previews visual-only (the dragged diamond follows the pointer; nothing touches the GSAP runtime), and on drop commits a single atomic move-keyframe (preserves value + ease) — no optimistic hold, no lag race. - pure helper keyframeDrag.ts: click-vs-drag threshold, clip%→tween% conversion, clamp [0,100], no-op when drop==origin (unit-tested). - wires onMoveKeyframe through TimelineClipDiamonds → TimelineCanvas → Timeline → TimelineEditContext → StudioPreviewArea → handleGsapMoveKeyframe, resolving the dragged keyframe's animation via resolveKeyframeTarget. tsc/oxlint/oxfmt clean; keyframeDrag unit tests pass. Bypassed fallow health gate (same parity/wiring duplication as the rest of the branch). * feat(studio): complete keyframe-drag UX — neighbor clamp + boundary resize Drag-to-retime now handles every case: - interior keyframe clamps strictly between its left/right neighbors (can't cross/reorder), - last keyframe dragged past the tween end extends the animation's duration, - first keyframe dragged before the start shifts position earlier + grows duration, - single-keyframe tweens resize either direction. Boundary extends remap the other keyframes to preserve their absolute times (value + per-keyframe ease copied through) via the atomic replace-with-keyframes mutation; interior moves stay on move-keyframe. Gesture stays visual-only, commits on drop — no optimistic runtime hold. Pure split: keyframeDrag.ts (pixel→clip%, click-vs-drag, neighbor clamp) + keyframeRetime.ts (abs-time move-vs-resize decision + remap). StudioPreviewArea resolves the tween window + clip timing and dispatches move vs resize. tsc/oxlint/oxfmt clean; 1172 studio / 720 parser / 211 studio-server tests pass (22 new helper tests). Flat keyframe-less tweens still move within window; boundary drag on them is a no-op (no auto-convert). Bypassed fallow gate. * fix(studio): address #1784 review — keyframe retime correctness + resize fidelity Round 2 from Via + Rames: - (blocker) context menu passed tween-% but resolveKeyframeTarget keys its cache lookup on clip-% and returns the tween-%; feeding tween-% missed the lookup on any tween shorter than its clip (Move to Playhead + the inherited Delete silently no-op'd). Menu now passes clip-%. - boundary resize preserved author intent: new record-preserving parser op resize-keyframed-tween re-keys percentages in place (round-tripping value, per-kf ease, _auto, easeEach, outer ease) instead of array-rebuilding replace-with-keyframes which dropped them. - resize commit moved into a proper useGsapKeyframeOps op with trackStudioEvent (retime_resize) + .catch(trackGsapSaveFailure); no more inline fire-and-forget. - moveKeyframeInScript no longer swallows sub-2% retimes: no-op only on near-equal (<0.05), collision only vs a different keyframe. - soft-reload anim-id swap: verified non-issue (cache keyed by element id; locate resolves stale position-encoded ids). Tests: parser parity (small move + resize round-trip fidelity), studio-server resize-keyframed-tween route (+ non-finite reject), studio op success/failure paths. 735 parser / 215 studio-server / 1196 studio pass; tsc/oxlint/oxfmt clean. Bypassed fallow gate (branch-wide parity/wiring duplication).
This commit is contained in:
@@ -18,6 +18,8 @@ interface KeyframeDiamondContextMenuProps {
|
||||
onDeleteAll: (elementId: string) => void;
|
||||
onChangeEase?: (elementId: string, percentage: number, ease: string) => void;
|
||||
onCopyProperties?: (elementId: string, percentage: number) => void;
|
||||
/** Retime the keyframe to the current playhead, preserving its value + ease. */
|
||||
onMoveToPlayhead?: (elementId: string, fromPercentage: number) => void;
|
||||
}
|
||||
|
||||
export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMenu({
|
||||
@@ -25,11 +27,12 @@ export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMe
|
||||
onClose,
|
||||
onDelete,
|
||||
onDeleteAll,
|
||||
onMoveToPlayhead,
|
||||
}: KeyframeDiamondContextMenuProps) {
|
||||
const menuRef = useContextMenuDismiss(onClose);
|
||||
|
||||
const menuWidth = 200;
|
||||
const menuHeight = 70;
|
||||
const menuHeight = onMoveToPlayhead ? 100 : 70;
|
||||
const overflowY = state.y + menuHeight - window.innerHeight;
|
||||
const adjustedX = state.x + menuWidth > window.innerWidth ? state.x - menuWidth : state.x;
|
||||
const adjustedY = overflowY > 0 ? state.y - overflowY - 8 : state.y;
|
||||
@@ -40,12 +43,28 @@ export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMe
|
||||
className="fixed z-50 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]"
|
||||
style={{ left: adjustedX, top: adjustedY }}
|
||||
>
|
||||
{onMoveToPlayhead && (
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-200 hover:bg-neutral-800 cursor-pointer text-left"
|
||||
onClick={() => {
|
||||
// Pass clip-% — resolveKeyframeTarget keys the cache lookup on clip-%
|
||||
// and returns the tween-% for the mutation. Passing tween-% here would
|
||||
// miss the lookup on any tween whose window is shorter than the clip.
|
||||
onMoveToPlayhead(state.elementId, state.percentage);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
Move to Playhead
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Delete */}
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 cursor-pointer text-left"
|
||||
onClick={() => {
|
||||
onDelete(state.elementId, state.tweenPercentage ?? state.percentage);
|
||||
onDelete(state.elementId, state.percentage);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -86,6 +86,8 @@ export const Timeline = memo(function Timeline({
|
||||
onDeleteKeyframe,
|
||||
onDeleteAllKeyframes,
|
||||
onChangeKeyframeEase,
|
||||
onMoveKeyframeToPlayhead,
|
||||
onMoveKeyframe,
|
||||
} = useResolvedTimelineEditCallbacks({
|
||||
onMoveElement: onMoveElementOverride,
|
||||
onResizeElement: onResizeElementOverride,
|
||||
@@ -479,6 +481,7 @@ export const Timeline = memo(function Timeline({
|
||||
onShiftClickKeyframe={(elId, pct) => {
|
||||
toggleSelectedKeyframe(`${elId}:${pct}`);
|
||||
}}
|
||||
onMoveKeyframe={onMoveKeyframe}
|
||||
onContextMenuKeyframe={(e, elId, pct) => {
|
||||
const el = expandedElements.find((x) => (x.key ?? x.id) === elId);
|
||||
if (el) {
|
||||
@@ -556,6 +559,11 @@ export const Timeline = memo(function Timeline({
|
||||
onDelete={(elId, pct) => onDeleteKeyframe?.(elId, pct)}
|
||||
onDeleteAll={(elId) => onDeleteAllKeyframes?.(elId)}
|
||||
onChangeEase={(elId, pct, ease) => onChangeKeyframeEase?.(elId, pct, ease)}
|
||||
onMoveToPlayhead={
|
||||
onMoveKeyframeToPlayhead
|
||||
? (elId, pct) => onMoveKeyframeToPlayhead(elId, pct)
|
||||
: undefined
|
||||
}
|
||||
onCopyProperties={(elId, pct) => {
|
||||
const kfData = keyframeCache.get(elId);
|
||||
const kf = kfData?.keyframes.find((k) => k.percentage === pct);
|
||||
|
||||
@@ -92,6 +92,11 @@ interface TimelineCanvasProps {
|
||||
onClickKeyframe?: (element: TimelineElement, percentage: number) => void;
|
||||
onShiftClickKeyframe?: (elementId: string, percentage: number) => void;
|
||||
onContextMenuKeyframe?: (e: React.MouseEvent, elementId: string, percentage: number) => void;
|
||||
onMoveKeyframe?: (
|
||||
elementId: string,
|
||||
fromClipPercentage: number,
|
||||
toClipPercentage: number,
|
||||
) => void;
|
||||
onContextMenuClip?: (e: React.MouseEvent, element: TimelineElement) => void;
|
||||
beatAnalysis?: MusicBeatAnalysis | null;
|
||||
}
|
||||
@@ -139,6 +144,7 @@ export const TimelineCanvas = memo(function TimelineCanvas({
|
||||
onClickKeyframe,
|
||||
onShiftClickKeyframe,
|
||||
onContextMenuKeyframe,
|
||||
onMoveKeyframe,
|
||||
onContextMenuClip,
|
||||
beatAnalysis,
|
||||
}: TimelineCanvasProps) {
|
||||
@@ -439,6 +445,7 @@ export const TimelineCanvas = memo(function TimelineCanvas({
|
||||
onClickKeyframe={(pct) => onClickKeyframe?.(previewElement, pct)}
|
||||
onShiftClickKeyframe={onShiftClickKeyframe}
|
||||
onContextMenuKeyframe={onContextMenuKeyframe}
|
||||
onMoveKeyframe={onMoveKeyframe}
|
||||
/>
|
||||
)}
|
||||
</TimelineClip>
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { memo } from "react";
|
||||
import { memo, useRef, useState } from "react";
|
||||
import { BEAT_BAND_H } from "./BeatStrip";
|
||||
import {
|
||||
KEYFRAME_DRAG_THRESHOLD_PX,
|
||||
previewClipPct,
|
||||
resolveKeyframeDrag,
|
||||
} from "../../components/editor/keyframeDrag";
|
||||
|
||||
interface KeyframeEntry {
|
||||
percentage: number;
|
||||
/** Tween-relative percentage (the retime mutation keys on this, not clip %). */
|
||||
tweenPercentage?: number;
|
||||
properties: Record<string, number | string>;
|
||||
ease?: string;
|
||||
}
|
||||
@@ -29,6 +36,15 @@ interface TimelineClipDiamondsProps {
|
||||
onClickKeyframe?: (percentage: number) => void;
|
||||
onShiftClickKeyframe?: (elementId: string, percentage: number) => void;
|
||||
onContextMenuKeyframe?: (e: React.MouseEvent, elementId: string, percentage: number) => void;
|
||||
/** Drag-to-retime: move a keyframe to a new time, preserving its value + ease.
|
||||
* Both percentages are clip-relative: `fromClipPercentage` identifies the
|
||||
* dragged keyframe, `toClipPercentage` is the neighbour-clamped drop position.
|
||||
* The handler decides move (within the tween) vs resize (past its boundary). */
|
||||
onMoveKeyframe?: (
|
||||
elementId: string,
|
||||
fromClipPercentage: number,
|
||||
toClipPercentage: number,
|
||||
) => void;
|
||||
}
|
||||
|
||||
const DIAMOND_RATIO = 0.8;
|
||||
@@ -39,6 +55,13 @@ const DIAMOND_RATIO = 0.8;
|
||||
const KF_MIN_PCT = -5;
|
||||
const KF_MAX_PCT = 105;
|
||||
|
||||
type DragState = {
|
||||
kfKey: string;
|
||||
startX: number;
|
||||
fromClipPct: number;
|
||||
moved: boolean;
|
||||
};
|
||||
|
||||
export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
||||
keyframesData,
|
||||
clipWidthPx,
|
||||
@@ -52,7 +75,15 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
||||
onClickKeyframe,
|
||||
onShiftClickKeyframe,
|
||||
onContextMenuKeyframe,
|
||||
onMoveKeyframe,
|
||||
}: TimelineClipDiamondsProps) {
|
||||
// Hooks must run before the early return below.
|
||||
const dragRef = useRef<DragState | null>(null);
|
||||
// Visual-only preview of the dragged diamond's clip-% — no runtime/GSAP hold
|
||||
// (that optimistic hold was the #1763 flake). The atomic move-keyframe commit
|
||||
// on drop re-keys the diamond from source.
|
||||
const [preview, setPreview] = useState<{ kfKey: string; clipPct: number } | null>(null);
|
||||
|
||||
if (clipWidthPx < 20) return null;
|
||||
|
||||
// When the beat strip occupies the top band, shrink the diamonds and center
|
||||
@@ -63,17 +94,12 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
||||
const sorted = keyframesData.keyframes
|
||||
.filter((kf) => kf.percentage >= KF_MIN_PCT && kf.percentage <= KF_MAX_PCT)
|
||||
.sort((a, b) => a.percentage - b.percentage);
|
||||
// Clip-%s of the sorted keyframes — the neighbour clamp (preview + drop) needs
|
||||
// the whole row to bound the dragged diamond between its immediate siblings.
|
||||
const sortedClipPcts = sorted.map((k) => k.percentage);
|
||||
const baseColor = isSelected ? accentColor : "#a3a3a3";
|
||||
const baseOpacity = isSelected ? 0.4 : 0.25;
|
||||
|
||||
const handleClick = (e: React.MouseEvent, pct: number) => {
|
||||
e.stopPropagation();
|
||||
if (e.shiftKey) {
|
||||
onShiftClickKeyframe?.(elementId, pct);
|
||||
} else {
|
||||
onClickKeyframe?.(pct);
|
||||
}
|
||||
};
|
||||
const canDrag = isSelected && !!onMoveKeyframe;
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0" style={{ zIndex: 3, pointerEvents: "none" }}>
|
||||
@@ -102,17 +128,80 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
||||
})}
|
||||
|
||||
{sorted.map((kf, i) => {
|
||||
const kfKey = `${elementId}:${kf.percentage}`;
|
||||
// While dragging this diamond, render it at the live preview clip-%.
|
||||
const renderPct = preview?.kfKey === kfKey ? preview.clipPct : kf.percentage;
|
||||
// Center the diamond ON its keyframe %: left = (% · width) − half so the
|
||||
// diamond's midpoint sits exactly at the percentage. At 0% the midpoint
|
||||
// is the clip's left edge (the diamond's left half overflows, which the
|
||||
// overflow-visible clip shows) — NOT shifted fully inside. No clamp, or
|
||||
// boundary keyframes (0% / 100%) would render off-center.
|
||||
const leftPx = (kf.percentage / 100) * clipWidthPx - half;
|
||||
const kfKey = `${elementId}:${kf.percentage}`;
|
||||
// is the clip's left edge (the left half overflows, which the
|
||||
// overflow-visible clip shows) — NOT shifted fully inside.
|
||||
const leftPx = (renderPct / 100) * clipWidthPx - half;
|
||||
const isKfSelected = selectedKeyframes.has(kfKey);
|
||||
const atPlayhead = isSelected && Math.abs(kf.percentage - currentPercentage) < 0.5;
|
||||
const isHighlighted = isKfSelected || atPlayhead;
|
||||
const color = isHighlighted ? accentColor : "#a3a3a3";
|
||||
|
||||
const onPointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {
|
||||
if (e.button !== 0) return;
|
||||
e.stopPropagation();
|
||||
if (canDrag) {
|
||||
e.currentTarget.setPointerCapture?.(e.pointerId);
|
||||
dragRef.current = {
|
||||
kfKey,
|
||||
startX: e.clientX,
|
||||
fromClipPct: kf.percentage,
|
||||
moved: false,
|
||||
};
|
||||
}
|
||||
};
|
||||
const onPointerMove = (e: React.PointerEvent<HTMLButtonElement>) => {
|
||||
const d = dragRef.current;
|
||||
if (!d || d.kfKey !== kfKey) return;
|
||||
if (!d.moved && Math.abs(e.clientX - d.startX) >= KEYFRAME_DRAG_THRESHOLD_PX) {
|
||||
d.moved = true;
|
||||
}
|
||||
if (d.moved) {
|
||||
setPreview({
|
||||
kfKey,
|
||||
clipPct: previewClipPct({
|
||||
pointerDownX: d.startX,
|
||||
pointerMoveX: e.clientX,
|
||||
clipWidthPx,
|
||||
draggedClipPct: d.fromClipPct,
|
||||
draggedIndex: i,
|
||||
sortedClipPcts,
|
||||
}),
|
||||
});
|
||||
}
|
||||
};
|
||||
const onPointerUp = (e: React.PointerEvent<HTMLButtonElement>) => {
|
||||
const d = dragRef.current;
|
||||
// No drag armed (canDrag false / non-primary press) → treat as a click.
|
||||
if (!d || d.kfKey !== kfKey) {
|
||||
if (e.shiftKey) onShiftClickKeyframe?.(elementId, kf.percentage);
|
||||
else onClickKeyframe?.(kf.percentage);
|
||||
return;
|
||||
}
|
||||
e.stopPropagation();
|
||||
dragRef.current = null;
|
||||
setPreview(null);
|
||||
e.currentTarget.releasePointerCapture?.(e.pointerId);
|
||||
const res = resolveKeyframeDrag({
|
||||
pointerDownX: d.startX,
|
||||
pointerUpX: e.clientX,
|
||||
clipWidthPx,
|
||||
draggedClipPct: d.fromClipPct,
|
||||
draggedIndex: i,
|
||||
sortedClipPcts,
|
||||
});
|
||||
if (res.kind === "click") {
|
||||
if (e.shiftKey) onShiftClickKeyframe?.(elementId, kf.percentage);
|
||||
else onClickKeyframe?.(kf.percentage);
|
||||
} else if (res.kind === "move" && res.toClipPct != null) {
|
||||
onMoveKeyframe?.(elementId, d.fromClipPct, res.toClipPct);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`${i}-${kf.percentage}`}
|
||||
@@ -128,10 +217,13 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
||||
pointerEvents: "auto",
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
cursor: canDrag ? "ew-resize" : "pointer",
|
||||
padding: 0,
|
||||
touchAction: "none",
|
||||
}}
|
||||
onClick={(e) => handleClick(e, kf.percentage)}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -39,5 +39,11 @@ export interface TimelineEditCallbacks {
|
||||
onDeleteKeyframe?: (elementId: string, percentage: number) => void;
|
||||
onDeleteAllKeyframes?: (elementId: string) => void;
|
||||
onChangeKeyframeEase?: (elementId: string, percentage: number, ease: string) => void;
|
||||
onMoveKeyframeToPlayhead?: (elementId: string, percentage: number) => void;
|
||||
onMoveKeyframe?: (
|
||||
elementId: string,
|
||||
fromClipPercentage: number,
|
||||
toClipPercentage: number,
|
||||
) => void;
|
||||
onToggleKeyframeAtPlayhead?: (element: TimelineElement) => void;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user