mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:52:44 +00:00
* 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>
167 lines
6.4 KiB
TypeScript
167 lines
6.4 KiB
TypeScript
import { memo, useRef, useState } from "react";
|
|
import { moveBeatCompositionTime, deleteBeatAtCompositionTime } from "../../utils/beatEditActions";
|
|
import { usePlayerStore } from "../store/playerStore";
|
|
import { CLIP_Y } from "./timelineLayout";
|
|
|
|
export const BEAT_BAND_H = 14; // dark band height at top of track
|
|
const BEAT_HIT_W = 12; // grab width per beat (px)
|
|
|
|
/** Hide both layers when beats are packed tighter than this (px) — too dense to read. */
|
|
function beatsTooDense(beatTimes: number[], pps: number): boolean {
|
|
if (beatTimes.length < 2) return true;
|
|
const avgInterval = (beatTimes[beatTimes.length - 1]! - beatTimes[0]!) / (beatTimes.length - 1);
|
|
return avgInterval * pps < 5;
|
|
}
|
|
|
|
/**
|
|
* Faint full-height beat lines painted into a track lane's background. Rendered
|
|
* behind the clips so they only show through the empty track area (the dots in
|
|
* BeatStrip mark beats on the clips themselves). Brightness scales with beat
|
|
* loudness. Drawn on every track lane for a global beat grid.
|
|
*/
|
|
export const BeatBackgroundLines = memo(function BeatBackgroundLines({
|
|
beatTimes,
|
|
beatStrengths,
|
|
pps,
|
|
highlightTime,
|
|
}: {
|
|
beatTimes: number[] | undefined;
|
|
beatStrengths: number[] | undefined;
|
|
pps: number;
|
|
/** Beat time a dragged clip will snap to — drawn as a bright neon line. */
|
|
highlightTime?: number | null;
|
|
}) {
|
|
if (!beatTimes || beatsTooDense(beatTimes, pps)) return null;
|
|
return (
|
|
<div className="absolute inset-0 pointer-events-none" style={{ zIndex: 0 }}>
|
|
{beatTimes.map((t, i) => {
|
|
const isHighlight = highlightTime != null && Math.abs(t - highlightTime) < 1e-3;
|
|
const strength = Math.pow(Math.min(1, beatStrengths?.[i] ?? 0.5), 2.2);
|
|
const opacity = isHighlight ? 1 : 0.06 + strength * 0.16;
|
|
return (
|
|
<div
|
|
key={`${t}-${i}`}
|
|
className="absolute top-0 bottom-0"
|
|
style={{
|
|
left: t * pps,
|
|
width: isHighlight ? 2 : 1,
|
|
background: `rgba(34,197,94,${opacity.toFixed(3)})`,
|
|
boxShadow: isHighlight ? "0 0 6px rgba(34,197,94,0.9)" : undefined,
|
|
zIndex: isHighlight ? 1 : undefined,
|
|
}}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
});
|
|
|
|
/**
|
|
* Green beat dots on the music track's row. Drag a dot to move its beat,
|
|
* double-click to delete; both scrub the audio. Dot size/brightness scale with
|
|
* beat loudness (gamma-curved for contrast).
|
|
*/
|
|
export const BeatStrip = memo(function BeatStrip({
|
|
beatTimes,
|
|
beatStrengths,
|
|
pps,
|
|
}: {
|
|
beatTimes: number[] | undefined;
|
|
beatStrengths: number[] | undefined;
|
|
pps: number;
|
|
}) {
|
|
// Active drag: which beat and how far (px) it's been dragged.
|
|
const [drag, setDrag] = useState<{ index: number; dx: number } | null>(null);
|
|
const dragRef = useRef<{ index: number; startX: number; origTime: number } | null>(null);
|
|
|
|
if (!beatTimes || beatsTooDense(beatTimes, pps)) return null;
|
|
const cy = BEAT_BAND_H / 2;
|
|
|
|
return (
|
|
<div
|
|
className="absolute left-0 right-0 pointer-events-none"
|
|
style={{ top: CLIP_Y, height: BEAT_BAND_H, background: "rgba(0,0,0,0.28)", zIndex: 11 }}
|
|
>
|
|
{beatTimes.map((t, i) => {
|
|
// Louder beats → larger, brighter dot. Gamma curve widens the contrast.
|
|
const strength = Math.pow(Math.min(1, beatStrengths?.[i] ?? 0.5), 2.2);
|
|
const r = 1.5 + strength * 2.5;
|
|
const opacity = 0.25 + strength * 0.75;
|
|
const dxPx = drag?.index === i ? drag.dx : 0;
|
|
const x = t * pps + dxPx;
|
|
return (
|
|
<div
|
|
key={`${t}-${i}`}
|
|
className="absolute select-none"
|
|
title="Drag to move · double-click to delete"
|
|
draggable={false}
|
|
style={{
|
|
left: x - BEAT_HIT_W / 2,
|
|
top: 0,
|
|
width: BEAT_HIT_W,
|
|
height: BEAT_BAND_H,
|
|
cursor: "ew-resize",
|
|
pointerEvents: "auto",
|
|
touchAction: "none",
|
|
}}
|
|
onPointerDown={(e) => {
|
|
// preventDefault stops the browser starting a native text/drag
|
|
// selection (which otherwise "selects" the whole panel mid-drag).
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
e.currentTarget.setPointerCapture(e.pointerId);
|
|
dragRef.current = { index: i, startX: e.clientX, origTime: t };
|
|
setDrag({ index: i, dx: 0 });
|
|
usePlayerStore.getState().setBeatDragging(true); // hide the playhead guideline
|
|
usePlayerStore.getState().requestSeek(Math.max(0, t)); // scrub audio at beat
|
|
}}
|
|
onPointerMove={(e) => {
|
|
const d = dragRef.current;
|
|
if (!d || d.index !== i) return;
|
|
e.preventDefault();
|
|
const dx = e.clientX - d.startX;
|
|
setDrag({ index: i, dx });
|
|
// Scrub the audio (and move the playhead) to follow the dragged beat.
|
|
usePlayerStore.getState().requestSeek(Math.max(0, d.origTime + dx / pps));
|
|
}}
|
|
onPointerUp={(e) => {
|
|
const d = dragRef.current;
|
|
dragRef.current = null;
|
|
setDrag(null);
|
|
usePlayerStore.getState().setBeatDragging(false);
|
|
if (e.currentTarget.hasPointerCapture?.(e.pointerId)) {
|
|
e.currentTarget.releasePointerCapture(e.pointerId);
|
|
}
|
|
if (!d || d.index !== i) return;
|
|
const dx = e.clientX - d.startX;
|
|
if (Math.abs(dx) > 2) {
|
|
const newTime = Math.max(0, d.origTime + dx / pps);
|
|
moveBeatCompositionTime(d.origTime, newTime);
|
|
usePlayerStore.getState().requestSeek(newTime); // park scrubber at new beat
|
|
}
|
|
}}
|
|
onDoubleClick={(e) => {
|
|
e.stopPropagation();
|
|
deleteBeatAtCompositionTime(t);
|
|
usePlayerStore.getState().requestSeek(Math.max(0, t)); // park scrubber at deleted beat
|
|
}}
|
|
>
|
|
<div
|
|
className="absolute"
|
|
style={{
|
|
left: BEAT_HIT_W / 2 - r,
|
|
top: cy - r,
|
|
width: r * 2,
|
|
height: r * 2,
|
|
borderRadius: "50%",
|
|
background: `rgba(34,197,94,${opacity.toFixed(3)})`,
|
|
pointerEvents: "none",
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
});
|