mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 17:30:50 +00:00
feat(studio): drag keyframes with live beat snapping (#1439)
* feat(studio): drag keyframes with beat snapping Keyframe diamonds are draggable with live preview and snap to the music beat grid (requires VITE_STUDIO_ENABLE_KEYFRAMES=1). Drag model: a tween start point trims the front (end fixed), an end point resizes (start fixed), an intermediate keyframe moves within the tween (adjacent segments resize, others untouched; start/end moves remap the intermediates to preserve their absolute times). The keyframe snaps to the nearest beat within ~8px, centered exactly on the dot. Reliability: the commit resolves the dragged element's selection + parsed animations on demand (awaited) instead of relying on the async DOM-edit session, picks the tween whose window contains the keyframe's original time among same-group tweens, and holds the dropped position optimistically until the cache round-trip lands. Cache clip% precision raised to 0.001% so the marker lands exactly where dropped. Pure match/plan logic + unit tests in editor/keyframeMove.ts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com> * fix(studio): harden keyframe drag commit (review follow-ups) - pickKeyframeTween no longer falls back to ALL animations on a selector mismatch — it only picks among the dragged element's own tweens, so a class/compound-selector mismatch can't edit a different element. No match → no-op. - computeKeyframeMovePlan bails to a no-op when a keyframe-array tween's dragged keyframe can't be located (stale cache / precision drift) instead of falling through to an end-point resize that silently rescaled the whole tween and re-timed every keyframe. - usePopulateKeyframeCacheForFile clipPct now uses 0.001% precision (matching useGsapAnimationsForElement) so beat-snapped keyframes from the file-wide cache also center on the dot and the two caches agree. - The optimistic drag hold only releases once the cache reflects the committed position (a keyframe near the held %), so an unrelated cache rebuild no longer flashes the diamond back to its old spot. - A drag's document listeners are cleaned up on unmount, so an unmount mid-drag (clip delete / comp switch / zoom-out) no longer leaks them. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com> * feat(studio): shrink + lower keyframe diamonds under the beat strip When a clip's track shows the beat-dot strip (the top band), its keyframe diamonds and connecting lines render at 45% size and centered in the region below the band, so they don't collide with the dots. Full size and vertically centered otherwise. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com> * fix(studio): ignore keyframe re-drag during the optimistic-hold window After a drop, the diamond is held at its dropped position (via effPct) until the file round-trip lands, but `pct` passed to handlePointerDown still comes from props (the pre-drop position). Re-grabbing the same keyframe in that window would track the drag from a stale origin and commit against the wrong tween (or no-op via the stale-cache guard). Skip starting a drag while a hold is pending; it clears on the cache match (≤2s fallback). Click selection is unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com> --------- Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
Miguel Ángel
parent
d9f69f61e7
commit
e6da47d8f8
@@ -110,9 +110,14 @@ export function useGsapSelectionHandlers({
|
||||
);
|
||||
|
||||
const handleGsapUpdateMeta = useCallback(
|
||||
(animId: string, updates: { duration?: number; ease?: string; position?: number }) => {
|
||||
if (!domEditSelection) return;
|
||||
updateGsapMeta(domEditSelection, animId, updates);
|
||||
(
|
||||
animId: string,
|
||||
updates: { duration?: number; ease?: string; position?: number },
|
||||
selectionOverride?: DomEditSelection | null,
|
||||
) => {
|
||||
const sel = selectionOverride ?? domEditSelection ?? lastSelectionRef.current;
|
||||
if (!sel) return;
|
||||
updateGsapMeta(sel, animId, updates);
|
||||
},
|
||||
[domEditSelection, updateGsapMeta],
|
||||
);
|
||||
@@ -191,9 +196,16 @@ export function useGsapSelectionHandlers({
|
||||
);
|
||||
|
||||
const handleGsapAddKeyframe = useCallback(
|
||||
(animId: string, percentage: number, property: string, value: number | string) => {
|
||||
if (!domEditSelection) return;
|
||||
addKeyframe(domEditSelection, animId, percentage, property, value);
|
||||
(
|
||||
animId: string,
|
||||
percentage: number,
|
||||
property: string,
|
||||
value: number | string,
|
||||
selectionOverride?: DomEditSelection | null,
|
||||
) => {
|
||||
const sel = selectionOverride ?? domEditSelection ?? lastSelectionRef.current;
|
||||
if (!sel) return;
|
||||
addKeyframe(sel, animId, percentage, property, value);
|
||||
},
|
||||
[domEditSelection, addKeyframe],
|
||||
);
|
||||
@@ -208,9 +220,10 @@ export function useGsapSelectionHandlers({
|
||||
[domEditSelection, addKeyframeBatch, trackGsapHandlerFailure],
|
||||
);
|
||||
const handleGsapRemoveKeyframe = useCallback(
|
||||
(animId: string, percentage: number) => {
|
||||
if (!domEditSelection) return;
|
||||
removeKeyframe(domEditSelection, animId, percentage);
|
||||
(animId: string, percentage: number, selectionOverride?: DomEditSelection | null) => {
|
||||
const sel = selectionOverride ?? domEditSelection ?? lastSelectionRef.current;
|
||||
if (!sel) return;
|
||||
removeKeyframe(sel, animId, percentage);
|
||||
},
|
||||
[domEditSelection, removeKeyframe],
|
||||
);
|
||||
|
||||
@@ -283,9 +283,11 @@ export function useGsapAnimationsForElement(
|
||||
const tweenDur = anim.duration ?? elDuration;
|
||||
for (const k of kf.keyframes) {
|
||||
const absTime = toAbsoluteTime(tweenPos, tweenDur, k.percentage);
|
||||
// 0.001% precision (was 0.1%) so a beat-snapped keyframe centers exactly
|
||||
// on the beat dot, which is rendered at the true beat time.
|
||||
const clipPct =
|
||||
elDuration > 0
|
||||
? Math.round(((absTime - elStart) / elDuration) * 1000) / 10
|
||||
? Math.round(((absTime - elStart) / elDuration) * 100000) / 1000
|
||||
: k.percentage;
|
||||
allKeyframes.push({
|
||||
...k,
|
||||
@@ -381,9 +383,12 @@ export function usePopulateKeyframeCacheForFile(
|
||||
const elDuration = timelineEl?.duration ?? 1;
|
||||
const clipKeyframes = kfData.keyframes.map((kf) => {
|
||||
const absTime = toAbsoluteTime(tweenPos, tweenDur, kf.percentage);
|
||||
// 0.001% precision (matching useGsapAnimationsForElement above) so a
|
||||
// beat-snapped keyframe centers exactly on the beat dot and the two
|
||||
// caches agree on a keyframe's percentage.
|
||||
const clipPct =
|
||||
elDuration > 0
|
||||
? Math.round(((absTime - elStart) / elDuration) * 1000) / 10
|
||||
? Math.round(((absTime - elStart) / elDuration) * 100000) / 1000
|
||||
: kf.percentage;
|
||||
return {
|
||||
...kf,
|
||||
|
||||
Reference in New Issue
Block a user