mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +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>
369 lines
11 KiB
TypeScript
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();
|
|
}
|