Files
hyperframes/packages/studio/src/player/components/timelineEditing.ts
T
e6da47d8f8 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>
2026-06-14 17:23:54 -07:00

369 lines
11 KiB
TypeScript

import { formatTime } from "../lib/time";
import { roundToCenti } from "../../utils/rounding";
const roundToCentiseconds = roundToCenti;
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
const EDGE_TRACK_CREATE_THRESHOLD = 0.55;
const AUTO_SCROLL_EDGE_ZONE = 40;
const AUTO_SCROLL_MAX_SPEED = 12;
export interface TimelineMoveInput {
start: number;
track: number;
duration: number;
originClientX: number;
originClientY: number;
originScrollLeft?: number;
originScrollTop?: number;
currentScrollLeft?: number;
currentScrollTop?: number;
pixelsPerSecond: number;
trackHeight: number;
maxStart: number;
trackOrder: number[];
}
export interface TimelineResizeInput {
start: number;
duration: number;
originClientX: number;
pixelsPerSecond: number;
minStart: number;
maxEnd: number;
minDuration?: number;
playbackStart?: number;
playbackRate?: number;
}
export interface TimelineAutoScrollBounds {
left: number;
top: number;
right: number;
bottom: number;
}
export function resolveTimelineAutoScroll(
bounds: TimelineAutoScrollBounds,
clientX: number,
clientY: number,
): { x: number; y: number } {
const getAxisDelta = (start: number, end: number, pointer: number) => {
if (pointer < start + AUTO_SCROLL_EDGE_ZONE) {
const proximity = Math.max(0, 1 - (pointer - start) / AUTO_SCROLL_EDGE_ZONE);
return -Math.round(AUTO_SCROLL_MAX_SPEED * proximity);
}
if (pointer > end - AUTO_SCROLL_EDGE_ZONE) {
const proximity = Math.max(0, 1 - (end - pointer) / AUTO_SCROLL_EDGE_ZONE);
return Math.round(AUTO_SCROLL_MAX_SPEED * proximity);
}
return 0;
};
return {
x: getAxisDelta(bounds.left, bounds.right, clientX),
y: getAxisDelta(bounds.top, bounds.bottom, clientY),
};
}
export function resolveTimelineMove(
input: TimelineMoveInput,
clientX: number,
clientY: number,
): { start: number; track: number } {
const scrollDeltaX = (input.currentScrollLeft ?? 0) - (input.originScrollLeft ?? 0);
const scrollDeltaY = (input.currentScrollTop ?? 0) - (input.originScrollTop ?? 0);
const deltaTime =
(clientX - input.originClientX + scrollDeltaX) / Math.max(input.pixelsPerSecond, 1);
const trackDeltaRaw =
(clientY - input.originClientY + scrollDeltaY) / Math.max(input.trackHeight, 1);
const deltaTrack = Math.round(trackDeltaRaw);
const currentTrackIndex = Math.max(0, input.trackOrder.indexOf(input.track));
const desiredTrackIndex = currentTrackIndex + deltaTrack;
const nextTrackIndex = clamp(desiredTrackIndex, 0, Math.max(0, input.trackOrder.length - 1));
const minTrack = Math.min(...input.trackOrder);
const maxTrack = Math.max(...input.trackOrder);
let nextTrack = input.trackOrder[nextTrackIndex] ?? input.track;
const startedOnFirstTrack = currentTrackIndex === 0;
const startedOnLastTrack = currentTrackIndex === input.trackOrder.length - 1;
if (
startedOnFirstTrack &&
desiredTrackIndex < 0 &&
currentTrackIndex + trackDeltaRaw <= -EDGE_TRACK_CREATE_THRESHOLD
) {
nextTrack = minTrack - 1;
} else if (
startedOnLastTrack &&
desiredTrackIndex > input.trackOrder.length - 1 &&
currentTrackIndex + trackDeltaRaw >= input.trackOrder.length - 1 + EDGE_TRACK_CREATE_THRESHOLD
) {
nextTrack = maxTrack + 1;
}
return {
start: clamp(roundToCentiseconds(input.start + deltaTime), 0, Math.max(0, input.maxStart)),
track: nextTrack,
};
}
/**
* Snap a keyframe's clip-relative percentage to the nearest beat within ~8px,
* mapping through composition time (pct → time → nearest beat → pct). Returns
* the percentage unchanged when no beat is in range, so dragging stays free
* between beats.
*/
export function snapKeyframePctToBeat(
el: { start: number; duration: number },
pct: number,
beatTimes: number[] | undefined,
pixelsPerSecond: number,
): number {
if (!beatTimes || beatTimes.length === 0 || el.duration <= 0) return pct;
const t = el.start + (pct / 100) * el.duration;
const snapSecs = 8 / Math.max(pixelsPerSecond, 1);
let best = t;
let bestDist = snapSecs;
for (const bt of beatTimes) {
const d = Math.abs(bt - t);
if (d < bestDist) {
bestDist = d;
best = bt;
}
}
if (best === t) return pct;
return Math.max(0, Math.min(100, ((best - el.start) / el.duration) * 100));
}
export function resolveTimelineResize(
input: TimelineResizeInput,
edge: "start" | "end",
clientX: number,
): { start: number; duration: number; playbackStart?: number } {
const minDuration = Math.max(0.05, input.minDuration ?? 0.1);
const deltaTime = (clientX - input.originClientX) / Math.max(input.pixelsPerSecond, 1);
if (edge === "end") {
const nextDuration = clamp(
roundToCentiseconds(input.duration + deltaTime),
minDuration,
Math.max(minDuration, input.maxEnd - input.start),
);
return {
start: input.start,
duration: nextDuration,
playbackStart: input.playbackStart,
};
}
const playbackRate = Math.max(0.1, input.playbackRate ?? 1);
const maxLeftExtensionFromMedia =
input.playbackStart != null ? input.playbackStart / playbackRate : Number.POSITIVE_INFINITY;
const minDelta = -Math.min(input.start - input.minStart, maxLeftExtensionFromMedia);
const maxDelta = input.duration - minDuration;
const clampedDelta = clamp(deltaTime, minDelta, maxDelta);
const nextStart = roundToCentiseconds(input.start + clampedDelta);
const nextDuration = roundToCentiseconds(input.duration - clampedDelta);
const nextPlaybackStart =
input.playbackStart != null
? roundToCentiseconds(Math.max(0, input.playbackStart + clampedDelta * playbackRate))
: undefined;
return {
start: nextStart,
duration: nextDuration,
playbackStart: nextPlaybackStart,
};
}
export interface TimelinePromptElement {
id: string;
tag: string;
start: number;
duration: number;
track: number;
}
export interface TimelineEditCapabilities {
canMove: boolean;
canTrimStart: boolean;
canTrimEnd: boolean;
}
export type BlockedTimelineEditIntent = "move" | "resize-start" | "resize-end";
export interface TimelineRangeSelection {
start: number;
end: number;
anchorX: number;
anchorY: number;
}
function isDeterministicTimelineWindow(input: {
tag: string;
compositionSrc?: string;
playbackStartAttr?: "media-start" | "playback-start";
sourceDuration?: number;
}): boolean {
if (input.compositionSrc) return true;
if (input.playbackStartAttr != null) return true;
if (
input.sourceDuration != null &&
Number.isFinite(input.sourceDuration) &&
input.sourceDuration > 0
) {
return true;
}
const normalizedTag = input.tag.toLowerCase();
return ["video", "audio", "img"].includes(normalizedTag);
}
export function hasPatchableTimelineTarget(input: { domId?: string; selector?: string }): boolean {
return Boolean(input.domId || input.selector);
}
export function getTimelineEditCapabilities(input: {
tag: string;
duration: number;
domId?: string;
selector?: string;
compositionSrc?: string;
playbackStart?: number;
playbackStartAttr?: "media-start" | "playback-start";
sourceDuration?: number;
timingSource?: "authored" | "implicit";
timelineLocked?: boolean;
}): TimelineEditCapabilities {
if (input.timingSource === "implicit" || input.timelineLocked) {
return {
canMove: false,
canTrimStart: false,
canTrimEnd: false,
};
}
const canPatch = hasPatchableTimelineTarget(input);
const hasFiniteDuration = Number.isFinite(input.duration) && input.duration > 0;
const hasDeterministicWindow = isDeterministicTimelineWindow(input);
return {
canMove: canPatch && (hasDeterministicWindow || hasFiniteDuration),
canTrimEnd: canPatch && hasFiniteDuration,
canTrimStart: canPatch && hasFiniteDuration,
};
}
export function resolveBlockedTimelineEditIntent(input: {
width: number;
offsetX: number;
handleWidth: number;
capabilities: TimelineEditCapabilities;
}): BlockedTimelineEditIntent | null {
if (input.capabilities.canMove) {
return null;
}
const safeWidth = Math.max(0, input.width);
const safeOffsetX = clamp(input.offsetX, 0, safeWidth);
const safeHandleWidth = Math.max(0, input.handleWidth);
if (safeOffsetX <= safeHandleWidth && !input.capabilities.canTrimStart) {
return "resize-start";
}
if (safeOffsetX >= Math.max(0, safeWidth - safeHandleWidth) && !input.capabilities.canTrimEnd) {
return "resize-end";
}
return "move";
}
export function buildClipRangeSelection(
clip: { start: number; duration: number },
anchor: { anchorX: number; anchorY: number },
): TimelineRangeSelection {
return {
start: clip.start,
end: clip.start + clip.duration,
anchorX: anchor.anchorX,
anchorY: anchor.anchorY,
};
}
export function buildTimelineAgentPrompt({
rangeStart,
rangeEnd,
elements,
prompt,
}: {
rangeStart: number;
rangeEnd: number;
elements: TimelinePromptElement[];
prompt: string;
}): string {
const start = Math.min(rangeStart, rangeEnd);
const end = Math.max(rangeStart, rangeEnd);
const elementLines = elements
.map(
(el) =>
`- #${el.id} (${el.tag}) — ${formatTime(el.start)} to ${formatTime(el.start + el.duration)}, track ${el.track}`,
)
.join("\n");
return `Edit the following HyperFrames composition:
Time range: ${formatTime(start)}${formatTime(end)}
Elements in range:
${elementLines || "(none)"}
User request:
${prompt.trim() || "(no prompt provided)"}
Instructions:
Modify only the elements listed above within the specified time range.
The composition uses HyperFrames data attributes (data-start, data-duration, data-track-index) and GSAP for animations.
Preserve all other elements and timing outside this range.`;
}
export function buildPromptCopyText(prompt: string): string {
return prompt.trim();
}
export function buildTimelineElementAgentPrompt(element: {
id: string;
tag: string;
start: number;
duration: number;
track: number;
sourceFile?: string;
selector?: string;
compositionSrc?: string;
}): string {
const lines = [
"Studio cannot directly move or resize this timeline clip because its visible timing is not fully controlled by patchable HTML timing attributes.",
"",
"Please update the source so the clip's actual visible timing stays consistent with the authored timeline.",
"",
"Clip:",
`- id: ${element.id}`,
`- tag: ${element.tag}`,
`- time: ${formatTime(element.start)} to ${formatTime(element.start + element.duration)}`,
`- track: ${element.track}`,
];
if (element.sourceFile) lines.push(`- source file: ${element.sourceFile}`);
if (element.selector) lines.push(`- selector: ${element.selector}`);
if (element.compositionSrc) lines.push(`- composition src: ${element.compositionSrc}`);
lines.push(
"",
"If this clip is animated with GSAP or another JS timeline, update the authored animation timing there as well instead of only changing data-start/data-duration.",
);
return lines.join("\n");
}
export function formatTimelineAttributeNumber(value: number): string {
return Number(roundToCentiseconds(value).toFixed(2)).toString();
}