Files
hyperframes/packages/studio/src/player/components/Timeline.tsx
T
Miguel Angel Simon Sierra 5e3ca6ae63 feat(studio): drag a clip past the video end to extend its duration
Dragging a timeline clip (or its right resize edge) past the end of the
video now extends the composition duration on drop, instead of clamping
the clip at the current end. Extend-only and undoable.

- Relax the move/resize horizontal clamps that pinned a clip's end at the
  current duration; effectiveDuration now folds in the active drag/resize
  preview so the ruler and track width grow live as you drag past the end.
- On drop, extend the root composition data-duration (and the store) when
  the clip's new end exceeds it, via a shared extendRootDurationInSource
  helper extracted from the block installer (now the single owner of that
  logic). An extending edit routes through the server persist path since
  the SDK setTiming op can't express the root composition's own duration.
2026-07-08 23:46:27 -04:00

595 lines
20 KiB
TypeScript

import { useRef, useMemo, useCallback, useState, useEffect, memo } from "react";
import { useMusicBeatAnalysis } from "../../hooks/useMusicBeatAnalysis";
import { isMusicTrack } from "../../utils/timelineInspector";
import { remapBeatAnalysisToComposition } from "../../utils/beatEditActions";
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
import { useExpandedTimelineElements } from "../hooks/useExpandedTimelineElements";
import { useMountEffect } from "../../hooks/useMountEffect";
import { EditPopover } from "./EditModal";
import { defaultTimelineTheme } from "./timelineTheme";
import { useTimelineRangeSelection } from "./useTimelineRangeSelection";
import { useTimelinePlayhead } from "./useTimelinePlayhead";
import { useTimelineActiveClips } from "./useTimelineActiveClips";
import { type TrackVisualStyle, getTrackStyle } from "./timelineIcons";
import { getTimelinePixelsPerSecond } from "./timelineZoom";
import { useTimelineZoom } from "./useTimelineZoom";
import { useTimelineAssetDrop } from "./timelineDragDrop";
import { TimelineEmptyState } from "./TimelineEmptyState";
import { TimelineCanvas } from "./TimelineCanvas";
import {
KeyframeDiamondContextMenu,
type KeyframeDiamondContextMenuState,
} from "./KeyframeDiamondContextMenu";
import { useTimelineClipDrag } from "./useTimelineClipDrag";
import { ClipContextMenu } from "./ClipContextMenu";
import { TimelineShortcutHint } from "./TimelineShortcutHint";
import { buildStackingTimelineLayers, insertPreviewTrackOrder } from "./timelineTrackOrder";
import { getTimelineLayerGroupHeaderTotalHeight } from "./TimelineLayerGroupHeader";
import {
GUTTER,
generateTicks,
getTimelineCanvasHeight,
shouldShowTimelineShortcutHint,
} from "./timelineLayout";
import { useResolvedTimelineEditCallbacks } from "./useResolvedTimelineEditCallbacks";
import type { TimelineProps } from "./TimelineTypes";
// Re-export pure utilities so existing imports from "./Timeline" still resolve.
export {
generateTicks,
formatTimelineTickLabel,
shouldAutoScrollTimeline,
getTimelineScrollLeftForZoomTransition,
getTimelineScrollLeftForZoomAnchor,
getTimelinePlayheadLeft,
getTimelineCanvasHeight,
shouldShowTimelineShortcutHint,
resolveTimelineAssetDrop,
shouldHandleTimelineDeleteKey,
getDefaultDroppedTrack,
} from "./timelineLayout";
export const Timeline = memo(function Timeline({
onSeek,
onDrillDown,
renderClipContent,
renderClipOverlay,
onFileDrop,
onAssetDrop,
onBlockDrop,
onDeleteElement: _onDeleteElement,
onMoveElement: onMoveElementOverride,
onResizeElement: onResizeElementOverride,
onBlockedEditAttempt: onBlockedEditAttemptOverride,
onSplitElement: onSplitElementOverride,
onSelectElement,
theme: themeOverrides,
}: TimelineProps = {}) {
const {
onMoveElement,
onResizeElement,
onBlockedEditAttempt,
onSplitElement,
onRazorSplitAll,
onDeleteKeyframe,
onDeleteAllKeyframes,
onChangeKeyframeEase,
onMoveKeyframeToPlayhead,
onMoveKeyframe,
} = useResolvedTimelineEditCallbacks({
onMoveElement: onMoveElementOverride,
onResizeElement: onResizeElementOverride,
onBlockedEditAttempt: onBlockedEditAttemptOverride,
onSplitElement: onSplitElementOverride,
});
const theme = useMemo(() => ({ ...defaultTimelineTheme, ...themeOverrides }), [themeOverrides]);
useMusicBeatAnalysis();
const rawElements = usePlayerStore((s) => s.elements);
const expandedElements = useExpandedTimelineElements();
const beatAnalysis = usePlayerStore((s) => s.beatAnalysis);
const musicElement = usePlayerStore((s) => s.elements.find(isMusicTrack) ?? null);
const beatEdits = usePlayerStore((s) => s.beatEdits);
const adjustedBeatAnalysis = useMemo(
() => remapBeatAnalysisToComposition(beatAnalysis, musicElement, beatEdits),
[beatAnalysis, musicElement, beatEdits],
);
const duration = usePlayerStore((s) => s.duration);
const timelineReady = usePlayerStore((s) => s.timelineReady);
const selectedElementId = usePlayerStore((s) => s.selectedElementId);
const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId);
const currentTime = usePlayerStore((s) => s.currentTime);
const { zoomMode, manualZoomPercent, setZoomMode, setManualZoomPercent } = useTimelineZoom();
const playheadRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const activeTool = usePlayerStore((s) => s.activeTool);
const [hoveredClip, setHoveredClip] = useState<string | null>(null);
const isDragging = useRef(false);
const [shiftHeld, setShiftHeld] = useState(false);
const [razorGuideX, setRazorGuideX] = useState<number | null>(null);
useMountEffect(() => {
const down = (e: KeyboardEvent) => e.key === "Shift" && setShiftHeld(true);
const up = (e: KeyboardEvent) => e.key === "Shift" && setShiftHeld(false);
const blur = () => setShiftHeld(false);
window.addEventListener("keydown", down);
window.addEventListener("keyup", up);
window.addEventListener("blur", blur);
return () => {
window.removeEventListener("keydown", down);
window.removeEventListener("keyup", up);
window.removeEventListener("blur", blur);
};
});
const [showPopover, setShowPopover] = useState(false);
const [showShortcutHint, setShowShortcutHint] = useState(true);
const [kfContextMenu, setKfContextMenu] = useState<KeyframeDiamondContextMenuState | null>(null);
const [clipContextMenu, setClipContextMenu] = useState<{
x: number;
y: number;
element: TimelineElement;
} | null>(null);
const [viewportWidth, setViewportWidth] = useState(0);
const roRef = useRef<ResizeObserver | null>(null);
const shortcutHintRafRef = useRef(0);
const syncShortcutHintVisibility = useCallback(() => {
const scroll = scrollRef.current;
setShowShortcutHint(
scroll ? shouldShowTimelineShortcutHint(scroll.scrollHeight, scroll.clientHeight) : true,
);
}, []);
const scheduleShortcutHintVisibilitySync = useCallback(() => {
if (shortcutHintRafRef.current) cancelAnimationFrame(shortcutHintRafRef.current);
shortcutHintRafRef.current = requestAnimationFrame(() => {
shortcutHintRafRef.current = 0;
syncShortcutHintVisibility();
});
}, [syncShortcutHintVisibility]);
const setContainerRef = useCallback((el: HTMLDivElement | null) => {
containerRef.current = el;
}, []);
const setScrollRef = useCallback(
(el: HTMLDivElement | null) => {
if (roRef.current) {
roRef.current.disconnect();
roRef.current = null;
}
scrollRef.current = el;
if (!el) return;
const syncScrollViewport = () => {
setViewportWidth(el.clientWidth);
scheduleShortcutHintVisibilitySync();
};
syncScrollViewport();
roRef.current = new ResizeObserver(syncScrollViewport);
roRef.current.observe(el);
},
[scheduleShortcutHintVisibilitySync],
);
useMountEffect(() => () => {
roRef.current?.disconnect();
if (shortcutHintRafRef.current) cancelAnimationFrame(shortcutHintRafRef.current);
});
const tracks = useMemo(
() => buildStackingTimelineLayers(expandedElements).rows,
[expandedElements],
);
const trackStyles = useMemo(() => {
const map = new Map<string, TrackVisualStyle>();
for (const layer of tracks) {
map.set(layer.id, getTrackStyle(layer.elements[0]?.tag ?? ""));
}
return map;
}, [tracks]);
const trackOrder = useMemo(() => tracks.map((layer) => layer.id), [tracks]);
const trackOrderRef = useRef(trackOrder);
trackOrderRef.current = trackOrder;
const timelineLayersRef = useRef(tracks);
timelineLayersRef.current = tracks;
const expandedElementsRef = useRef(expandedElements);
expandedElementsRef.current = expandedElements;
const ppsRef = useRef(100);
const durationRef = useRef(Number.isFinite(duration) ? duration : 0);
// Stable ref so useTimelineClipDrag can clear rangeSelection without circular dep
const setRangeSelectionRef = useRef<((sel: null) => void) | null>(null);
const {
draggedClip,
setDraggedClip,
resizingClip,
setResizingClip,
blockedClipRef,
suppressClickRef,
syncClipDragAutoScroll,
} = useTimelineClipDrag({
scrollRef,
ppsRef,
trackOrderRef,
timelineLayersRef,
timelineElementsRef: expandedElementsRef,
onMoveElement,
onResizeElement,
onBlockedEditAttempt,
setShowPopover,
setRangeSelectionRef,
});
const effectiveDuration = useMemo(() => {
const safeDur = Number.isFinite(duration) ? duration : 0;
let maxEnd = safeDur;
if (rawElements.length > 0) {
maxEnd = Math.max(maxEnd, ...rawElements.map((el) => el.start + el.duration));
}
if (draggedClip?.started) {
maxEnd = Math.max(maxEnd, draggedClip.previewStart + draggedClip.element.duration);
}
if (resizingClip?.started) {
maxEnd = Math.max(maxEnd, resizingClip.previewStart + resizingClip.previewDuration);
}
return Number.isFinite(maxEnd) ? maxEnd : safeDur;
}, [rawElements, duration, draggedClip, resizingClip]);
durationRef.current = effectiveDuration;
const displayTrackOrder = useMemo(() => {
if (
!draggedClip?.started ||
trackOrder.length === 0 ||
trackOrder.includes(draggedClip.previewLayerId)
)
return trackOrder;
return insertPreviewTrackOrder(
trackOrder,
draggedClip.previewLayerId,
draggedClip.previewLayerIndex,
);
}, [draggedClip, trackOrder]);
const totalH =
getTimelineCanvasHeight(displayTrackOrder.length) +
getTimelineLayerGroupHeaderTotalHeight(displayTrackOrder, tracks);
const keyframeCache = usePlayerStore((s) => s.keyframeCache);
const selectedKeyframes = usePlayerStore((s) => s.selectedKeyframes);
const toggleSelectedKeyframe = usePlayerStore((s) => s.toggleSelectedKeyframe);
const selectedElement = useMemo(
() =>
expandedElements.find((element) => (element.key ?? element.id) === selectedElementId) ?? null,
[expandedElements, selectedElementId],
);
const selectedElementRef = useRef<TimelineElement | null>(selectedElement);
selectedElementRef.current = selectedElement;
const fitPps =
viewportWidth > GUTTER && effectiveDuration > 0
? (viewportWidth - GUTTER - 2) / effectiveDuration
: 100;
const pps = getTimelinePixelsPerSecond(fitPps, zoomMode, manualZoomPercent);
ppsRef.current = pps;
const trackContentWidth = Math.max(0, effectiveDuration * pps);
const clipStateVersion = useMemo(
() =>
expandedElements
.map((el) => `${el.key ?? el.id}:${el.start}:${el.duration}:${el.track}`)
.join("|"),
[expandedElements],
);
const zoomModeRef = useRef(zoomMode);
zoomModeRef.current = zoomMode;
const manualZoomPercentRef = useRef(manualZoomPercent);
manualZoomPercentRef.current = manualZoomPercent;
const fitPpsRef = useRef(fitPps);
fitPpsRef.current = fitPps;
const { seekFromX, autoScrollDuringDrag, dragScrollRaf } = useTimelinePlayhead({
playheadRef,
scrollRef,
ppsRef,
durationRef,
isDragging,
currentTime,
zoomMode,
manualZoomPercent,
zoomModeRef,
manualZoomPercentRef,
fitPps,
fitPpsRef,
effectiveDuration,
pps,
timelineReady,
elementsLength: expandedElements.length,
setZoomMode,
setManualZoomPercent,
onSeek,
});
useTimelineActiveClips({
scrollRef,
currentTime,
clipStateVersion,
});
const {
rangeSelection,
setRangeSelection,
shiftClickClipRef,
handlePointerDown,
handlePointerMove,
handlePointerUp,
} = useTimelineRangeSelection({
scrollRef,
ppsRef,
effectiveDuration,
pps,
onSeek,
seekFromX,
autoScrollDuringDrag,
dragScrollRaf,
isDragging,
setShowPopover,
});
// Wire setRangeSelection into the stable ref consumed by useTimelineClipDrag
setRangeSelectionRef.current = setRangeSelection;
const prevSelectedRef = useRef(selectedElementRef.current);
// eslint-disable-next-line no-restricted-syntax, react-hooks/exhaustive-deps
useEffect(() => {
const prev = prevSelectedRef.current;
const curr = selectedElementRef.current;
prevSelectedRef.current = curr;
if (prev && !curr) {
setShowPopover(false);
setRangeSelection(null);
}
});
const { major, minor } = useMemo(
() => generateTicks(effectiveDuration, pps),
[effectiveDuration, pps],
);
const majorTickInterval = major.length >= 2 ? major[1] - major[0] : effectiveDuration;
useEffect(() => {
syncShortcutHintVisibility();
}, [syncShortcutHintVisibility, timelineReady, expandedElements.length, totalH]);
const getPreviewElement = useCallback(
(element: TimelineElement): TimelineElement => {
if (
resizingClip &&
(resizingClip.element.key ?? resizingClip.element.id) === (element.key ?? element.id)
) {
return {
...element,
start: resizingClip.previewStart,
duration: resizingClip.previewDuration,
playbackStart: resizingClip.previewPlaybackStart,
};
}
return element;
},
[resizingClip],
);
const { isDragOver, setIsDragOver, handleAssetDragOver, handleAssetDrop } = useTimelineAssetDrop({
scrollRef,
ppsRef,
durationRef,
trackOrderRef,
timelineLayersRef,
onFileDrop,
onAssetDrop,
onBlockDrop,
});
if (!timelineReady || expandedElements.length === 0) {
return (
<TimelineEmptyState
isDragOver={isDragOver}
onFileDrop={!!onFileDrop}
onDragOver={handleAssetDragOver}
onDragLeave={() => setIsDragOver(false)}
onDrop={handleAssetDrop}
/>
);
}
return (
<div
ref={setContainerRef}
aria-label="Timeline"
className={`relative border-t select-none h-full overflow-hidden ${activeTool === "razor" ? "cursor-crosshair" : shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
onMouseMove={(e) => {
if (activeTool === "razor" && scrollRef.current) {
const rect = scrollRef.current.getBoundingClientRect();
setRazorGuideX(e.clientX - rect.left + scrollRef.current.scrollLeft);
}
}}
onMouseLeave={() => setRazorGuideX(null)}
style={{
touchAction: "pan-x pan-y",
background: theme.shellBackground,
borderColor: theme.shellBorder,
}}
>
<div
ref={setScrollRef}
tabIndex={-1}
className={`${zoomMode === "fit" ? "overflow-x-hidden" : "overflow-x-auto"} overflow-y-auto h-full outline-none`}
onDragOver={handleAssetDragOver}
onDragLeave={() => setIsDragOver(false)}
onDrop={handleAssetDrop}
onPointerDown={(e) => {
if (activeTool === "razor" && e.shiftKey && e.button === 0 && scrollRef.current) {
const rect = scrollRef.current.getBoundingClientRect();
const x = e.clientX - rect.left + scrollRef.current.scrollLeft - GUTTER;
const splitTime = Math.max(0, x / pps);
onRazorSplitAll?.(splitTime);
return;
}
handlePointerDown(e);
}}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onLostPointerCapture={handlePointerUp}
>
<TimelineCanvas
major={major}
minor={minor}
pps={pps}
trackContentWidth={trackContentWidth}
totalH={totalH}
effectiveDuration={effectiveDuration}
majorTickInterval={majorTickInterval}
rangeSelection={rangeSelection}
theme={theme}
displayTrackOrder={displayTrackOrder}
trackOrder={trackOrder}
tracks={tracks}
trackStyles={trackStyles}
selectedElementId={selectedElementId}
hoveredClip={hoveredClip}
draggedClip={draggedClip}
resizingClip={resizingClip}
blockedClipRef={blockedClipRef}
suppressClickRef={suppressClickRef}
scrollRef={scrollRef}
renderClipContent={renderClipContent}
renderClipOverlay={renderClipOverlay}
playheadRef={playheadRef}
onDrillDown={onDrillDown}
onSelectElement={onSelectElement}
setHoveredClip={setHoveredClip}
setShowPopover={setShowPopover}
setRangeSelection={setRangeSelection}
setResizingClip={setResizingClip}
setDraggedClip={setDraggedClip}
setSelectedElementId={setSelectedElementId}
syncClipDragAutoScroll={syncClipDragAutoScroll}
shiftClickClipRef={shiftClickClipRef}
getPreviewElement={getPreviewElement}
getTrackStyle={getTrackStyle}
keyframeCache={keyframeCache}
selectedKeyframes={selectedKeyframes}
currentTime={currentTime}
beatAnalysis={adjustedBeatAnalysis}
onClickKeyframe={(el, pct) => {
usePlayerStore.getState().clearSelectedKeyframes();
const elKey = el.key ?? el.id;
setSelectedElementId(elKey);
onSelectElement?.(el);
// Visually select the clicked diamond (matches shift-click / motion-path
// selection); cleared above so this single-selects it.
toggleSelectedKeyframe(`${elKey}:${pct}`);
const absTime = el.start + (pct / 100) * el.duration;
onSeek?.(absTime);
const kfData = keyframeCache?.get(elKey);
const kf = kfData?.keyframes.find((k) => Math.abs(k.percentage - pct) < 0.5);
usePlayerStore.getState().setActiveKeyframePct(kf?.tweenPercentage ?? null);
}}
onShiftClickKeyframe={(elId, pct) => {
toggleSelectedKeyframe(`${elId}:${pct}`);
}}
onMoveKeyframe={onMoveKeyframe}
onContextMenuKeyframe={(e, elId, pct) => {
const el = expandedElements.find((x) => (x.key ?? x.id) === elId);
if (el) {
setSelectedElementId(elId);
onSelectElement?.(el);
}
const kfData = keyframeCache.get(elId);
const kf = kfData?.keyframes.find((k) => Math.abs(k.percentage - pct) < 0.2);
setKfContextMenu({
x: e.clientX + 4,
y: e.clientY + 2,
elementId: elId,
percentage: pct,
tweenPercentage: kf?.tweenPercentage,
currentEase: kf?.ease ?? kfData?.ease,
});
}}
onContextMenuClip={(e, el) => {
e.preventDefault();
setSelectedElementId(el.key ?? el.id);
onSelectElement?.(el);
setClipContextMenu({ x: e.clientX, y: e.clientY, element: el });
}}
/>
{activeTool === "razor" && razorGuideX !== null && (
<div
className="absolute top-0 bottom-0 pointer-events-none z-10"
style={{
left: razorGuideX,
width: 1,
background: "rgba(239,68,68,0.7)",
}}
/>
)}
</div>
{showShortcutHint && !showPopover && !rangeSelection && (
<TimelineShortcutHint theme={theme} />
)}
{showPopover && rangeSelection && (
<EditPopover
rangeStart={rangeSelection.start}
rangeEnd={rangeSelection.end}
anchorX={rangeSelection.anchorX}
anchorY={rangeSelection.anchorY}
onClose={() => {
setShowPopover(false);
setRangeSelection(null);
}}
/>
)}
{kfContextMenu && (
<KeyframeDiamondContextMenu
state={kfContextMenu}
onClose={() => setKfContextMenu(null)}
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);
if (kf) {
void navigator.clipboard.writeText(JSON.stringify(kf.properties, null, 2));
}
}}
/>
)}
{clipContextMenu && (
<ClipContextMenu
x={clipContextMenu.x}
y={clipContextMenu.y}
element={clipContextMenu.element}
currentTime={currentTime}
onClose={() => setClipContextMenu(null)}
onSplit={(el, time) => onSplitElement?.(el, time)}
onDelete={(el) => _onDeleteElement?.(el)}
/>
)}
</div>
);
});