@hyperframes/studio 0.7.86 → 0.7.88

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/dist/assets/{hyperframes-player-BL3qfGEQ.js → hyperframes-player-B03_wTTS.js} +1 -1
  2. package/dist/assets/{index-B5QGai9B.js → index-BNshnsvB.js} +1 -1
  3. package/dist/assets/index-BXkzp_OU.css +1 -0
  4. package/dist/assets/{index-C0Vr8jjc.js → index-D7KcL_t3.js} +1 -1
  5. package/dist/assets/{index-CISVI7UC.js → index-DbY124Po.js} +221 -221
  6. package/dist/index.d.ts +14 -6
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +6265 -4922
  9. package/dist/index.js.map +1 -1
  10. package/package.json +11 -8
  11. package/src/components/TimelineToolbar.tsx +14 -5
  12. package/src/components/nle/NLEContext.tsx +4 -1
  13. package/src/components/nle/TimelinePane.tsx +2 -0
  14. package/src/hooks/studioTestMode.test.ts +35 -0
  15. package/src/hooks/studioTestMode.ts +23 -0
  16. package/src/hooks/useGestureRecording.test.tsx +120 -0
  17. package/src/hooks/useGestureRecording.ts +49 -40
  18. package/src/hooks/useStudioTestHooks.test.tsx +161 -0
  19. package/src/hooks/useStudioTestHooks.ts +54 -7
  20. package/src/player/components/BeatStrip.tsx +21 -5
  21. package/src/player/components/CompositionThumbnail.test.ts +96 -2
  22. package/src/player/components/CompositionThumbnail.tsx +29 -16
  23. package/src/player/components/Timeline.test.ts +104 -0
  24. package/src/player/components/Timeline.tsx +141 -131
  25. package/src/player/components/Timeline.virtualization.test.tsx +400 -0
  26. package/src/player/components/TimelineCanvas.tsx +18 -10
  27. package/src/player/components/TimelineClip.tsx +4 -0
  28. package/src/player/components/TimelineClipDiamonds.test.tsx +85 -5
  29. package/src/player/components/TimelineClipDiamonds.tsx +53 -13
  30. package/src/player/components/TimelineLanes.test.tsx +15 -4
  31. package/src/player/components/TimelineLanes.tsx +81 -26
  32. package/src/player/components/TimelinePropertyLanes.test.tsx +16 -20
  33. package/src/player/components/TimelinePropertyLanes.tsx +1 -0
  34. package/src/player/components/TimelineRuler.tsx +21 -5
  35. package/src/player/components/TimelineTrackRow.tsx +46 -0
  36. package/src/player/components/TimelineTypes.ts +2 -0
  37. package/src/player/components/timelineClipDragGestureLifecycle.test.ts +97 -0
  38. package/src/player/components/timelineClipDragGestureLifecycle.ts +261 -0
  39. package/src/player/components/timelineDiamondTypes.ts +5 -0
  40. package/src/player/components/timelineDragDrop.ts +5 -5
  41. package/src/player/components/timelineEditing.test.ts +32 -0
  42. package/src/player/components/timelineEditing.ts +47 -5
  43. package/src/player/components/timelineFocusIdentity.test.ts +21 -0
  44. package/src/player/components/timelineFocusIdentity.ts +21 -0
  45. package/src/player/components/timelineLaneProps.ts +9 -0
  46. package/src/player/components/timelineLayout.test.ts +59 -0
  47. package/src/player/components/timelineLayout.ts +202 -176
  48. package/src/player/components/timelineRowVirtualizationFlag.test.ts +23 -0
  49. package/src/player/components/timelineRowVirtualizationFlag.ts +10 -0
  50. package/src/player/components/timelineRulerGeometry.ts +142 -0
  51. package/src/player/components/timelineViewModel.ts +44 -0
  52. package/src/player/components/timelineViewportGeometry.ts +51 -0
  53. package/src/player/components/timelineZoom.test.ts +42 -28
  54. package/src/player/components/timelineZoom.ts +42 -25
  55. package/src/player/components/useTimelineActiveClips.test.ts +82 -79
  56. package/src/player/components/useTimelineActiveClips.ts +49 -55
  57. package/src/player/components/useTimelineClipDrag.ts +40 -230
  58. package/src/player/components/useTimelineClipRenderWindow.ts +95 -0
  59. package/src/player/components/useTimelineGeometry.ts +4 -11
  60. package/src/player/components/useTimelinePlayhead.ts +30 -14
  61. package/src/player/components/useTimelineRangeSelection.ts +5 -4
  62. package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +3 -2
  63. package/src/player/components/useTimelineRevealClip.test.tsx +139 -0
  64. package/src/player/components/useTimelineRevealClip.ts +171 -47
  65. package/src/player/components/useTimelineRowVirtualization.ts +140 -0
  66. package/src/player/components/useTimelineScrollViewport.test.tsx +202 -0
  67. package/src/player/components/useTimelineScrollViewport.ts +87 -9
  68. package/src/player/components/useTimelineSelectionLifecycle.ts +29 -0
  69. package/src/player/components/useTimelineShiftModifier.ts +20 -0
  70. package/src/player/components/useTimelineTicks.ts +22 -0
  71. package/src/player/components/useTimelineTrackLayout.test.ts +32 -26
  72. package/src/player/components/useTimelineTrackLayout.ts +35 -18
  73. package/src/player/components/useTimelineVirtualRows.test.tsx +174 -0
  74. package/src/player/components/useTimelineVirtualRows.ts +126 -0
  75. package/src/player/hooks/timelinePlayerSync.ts +20 -0
  76. package/src/player/hooks/useTimelinePlayer.seek.test.ts +90 -51
  77. package/src/player/hooks/useTimelinePlayer.ts +8 -20
  78. package/src/player/lib/playbackScrub.ts +2 -2
  79. package/src/player/lib/timelineClipIndex.test.ts +169 -0
  80. package/src/player/lib/timelineClipIndex.ts +208 -0
  81. package/src/player/lib/timelineElementIndexes.test.ts +31 -0
  82. package/src/player/lib/timelineElementIndexes.ts +44 -0
  83. package/src/player/lib/timelinePerformanceDiagnostics.test.ts +100 -0
  84. package/src/player/lib/timelinePerformanceDiagnostics.ts +120 -0
  85. package/src/player/lib/timelinePerformanceFixture.ts +167 -0
  86. package/src/player/lib/timelineViewportBudgets.test.ts +60 -0
  87. package/src/player/lib/timelineViewportBudgets.ts +151 -0
  88. package/src/player/store/playerStore.test.ts +20 -3
  89. package/src/player/store/playerStore.ts +73 -45
  90. package/src/utils/studioHelpers.test.ts +48 -1
  91. package/src/utils/studioHelpers.ts +31 -34
  92. package/src/utils/studioUiPreferences.ts +3 -1
  93. package/dist/assets/index-D379YOKT.css +0 -1
@@ -1,14 +1,11 @@
1
- import { useRef, useMemo, useCallback, useState, useEffect, memo } from "react";
1
+ import { useRef, useMemo, useCallback, useState, memo } from "react";
2
2
  import { useMusicBeatAnalysis } from "../../hooks/useMusicBeatAnalysis";
3
- import { isMusicTrack } from "../../utils/timelineInspector";
4
3
  import { remapBeatAnalysisToComposition } from "../../utils/beatEditActions";
5
4
  import { usePlayerStore, type TimelineElement } from "../store/playerStore";
6
5
  import { useExpandedTimelineElements } from "../hooks/useExpandedTimelineElements";
7
- import { useMountEffect } from "../../hooks/useMountEffect";
8
6
  import { defaultTimelineTheme } from "./timelineTheme";
9
7
  import { useTimelineRangeSelection } from "./useTimelineRangeSelection";
10
8
  import { useTimelinePlayhead } from "./useTimelinePlayhead";
11
- import { useTimelineActiveClips } from "./useTimelineActiveClips";
12
9
  import { useTimelineZoom } from "./useTimelineZoom";
13
10
  import { useTimelineAssetDrop } from "./timelineDragDrop";
14
11
  import { TimelineEmptyState } from "./TimelineEmptyState";
@@ -16,14 +13,12 @@ import { TimelineCanvas } from "./TimelineCanvas";
16
13
  import { type KeyframeDiamondContextMenuState } from "./KeyframeDiamondContextMenu";
17
14
  import { useTimelineClipDrag } from "./useTimelineClipDrag";
18
15
  import { TimelineOverlays } from "./TimelineOverlays";
19
- import { animationContributesLane } from "./TimelinePropertyLanes";
20
16
  import { useTimelineEditPinning } from "./useTimelineEditPinning";
21
17
  import { useTimelineStackingSync } from "./useTimelineStackingSync";
22
18
  import { useTimelineGeometry } from "./useTimelineGeometry";
23
19
  import { useAutoExpandKeyframedClips } from "./useAutoExpandKeyframedClips";
24
- import { GUTTER, LABEL_COL_W, TRACKS_LEFT_PAD, generateTicks } from "./timelineLayout";
20
+ import { GUTTER, LABEL_COL_W, TRACKS_LEFT_PAD } from "./timelineLayout";
25
21
  import { useTimelineScrollViewport } from "./useTimelineScrollViewport";
26
- import { STUDIO_PREVIEW_FPS } from "../lib/time";
27
22
  import { useResolvedTimelineEditCallbacks } from "./useResolvedTimelineEditCallbacks";
28
23
  import type { TimelineProps } from "./TimelineTypes";
29
24
  import {
@@ -37,14 +32,25 @@ import { useTimelineGapHighlights } from "./useTimelineGapHighlights";
37
32
  import { useStudioPlaybackContextOptional } from "../../contexts/StudioContext";
38
33
  import { TimelineRazorGuide, useTimelineRazorInteraction } from "./TimelineRazorInteraction";
39
34
  import { useTimelinePerformanceTelemetry } from "./useTimelinePerformanceTelemetry";
35
+ import {
36
+ getEffectiveTimelineDuration,
37
+ getTimelinePreviewElement,
38
+ hasKeyframedTimelineClips,
39
+ } from "./timelineViewModel";
40
+ import { useTimelineSelectionLifecycle } from "./useTimelineSelectionLifecycle";
41
+ import { useTimelineShiftModifier } from "./useTimelineShiftModifier";
42
+ import { useTimelineTicks } from "./useTimelineTicks";
43
+ import { getTimelineElementIndexes } from "../lib/timelineElementIndexes";
44
+ import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
45
+ import { useTimelineRowVirtualization } from "./useTimelineRowVirtualization";
46
+ import { useTimelineClipRenderWindow } from "./useTimelineClipRenderWindow";
47
+ import { useTimelineActiveClips } from "./useTimelineActiveClips";
40
48
 
41
- // Re-export pure utilities so existing imports from "./Timeline" still resolve.
42
49
  export {
43
- generateTicks,
44
- formatTimelineTickLabel,
45
50
  shouldAutoScrollTimeline,
46
51
  getTimelineScrollLeftForZoomTransition,
47
52
  getTimelineScrollLeftForZoomAnchor,
53
+ getTimelinePlaybackFollowScrollLeft,
48
54
  getTimelinePlayheadLeft,
49
55
  getTimelineCanvasHeight,
50
56
  shouldShowTimelineShortcutHint,
@@ -52,6 +58,12 @@ export {
52
58
  shouldHandleTimelineDeleteKey,
53
59
  getDefaultDroppedTrack,
54
60
  } from "./timelineLayout";
61
+ export { formatTimelineTickLabel, generateTicks } from "./timelineRulerGeometry";
62
+
63
+ export {
64
+ getTimelineScrollTopForGeometryChange,
65
+ getTimelineVisibleTimeRange,
66
+ } from "./timelineViewportGeometry";
55
67
 
56
68
  export const Timeline = memo(function Timeline({
57
69
  onSeek,
@@ -71,6 +83,7 @@ export const Timeline = memo(function Timeline({
71
83
  onSplitElement: onSplitElementOverride,
72
84
  onSelectElement,
73
85
  theme: themeOverrides,
86
+ sessionEpoch = 0,
74
87
  }: TimelineProps = {}) {
75
88
  const {
76
89
  onMoveElement,
@@ -102,7 +115,7 @@ export const Timeline = memo(function Timeline({
102
115
  const rawElements = usePlayerStore((s) => s.elements);
103
116
  const expandedElements = useExpandedTimelineElements();
104
117
  const beatAnalysis = usePlayerStore((s) => s.beatAnalysis);
105
- const musicElement = usePlayerStore((s) => s.elements.find(isMusicTrack) ?? null);
118
+ const musicElement = usePlayerStore((s) => getTimelineElementIndexes(s.elements).musicElement);
106
119
  const beatEdits = usePlayerStore((s) => s.beatEdits);
107
120
  const adjustedBeatAnalysis = useMemo(
108
121
  () => remapBeatAnalysisToComposition(beatAnalysis, musicElement, beatEdits),
@@ -113,17 +126,11 @@ export const Timeline = memo(function Timeline({
113
126
  const timelineReady = usePlayerStore((s) => s.timelineReady);
114
127
  const selectedElementId = usePlayerStore((s) => s.selectedElementId);
115
128
  const selectedElementIds = usePlayerStore((s) => s.selectedElementIds);
129
+ const clipRevealRequest = usePlayerStore((s) => s.clipRevealRequest);
130
+ const focusedEaseSegment = usePlayerStore((s) => s.focusedEaseSegment);
116
131
  const gsapAnimations = usePlayerStore((s) => s.gsapAnimations);
117
- // Label mode = comp has keyframed clips (not just when expanded): keeps the layer
118
- // disclosure + property column visible and reserves a GUTTER before 0s (Figma).
119
132
  const hasKeyframedClips = useMemo(
120
- () =>
121
- Array.from(gsapAnimations.values()).some((list) =>
122
- // Same lane-contribution predicate the layout uses: real keyframes OR a
123
- // synthesizable flat tween. Checking animation.keyframes alone left a
124
- // flat-tween-only comp without its reserved label column.
125
- list.some((animation) => animationContributesLane(animation)),
126
- ),
133
+ () => hasKeyframedTimelineClips(gsapAnimations),
127
134
  [gsapAnimations],
128
135
  );
129
136
  const labelMode = hasKeyframedClips;
@@ -135,28 +142,13 @@ export const Timeline = memo(function Timeline({
135
142
  const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId);
136
143
  const currentTime = usePlayerStore((s) => s.currentTime);
137
144
  const { zoomMode, manualZoomPercent, setZoomMode, setManualZoomPercent } = useTimelineZoom();
138
-
139
145
  const playheadRef = useRef<HTMLDivElement>(null);
140
146
  const containerRef = useRef<HTMLDivElement>(null);
141
147
  const scrollRef = useRef<HTMLDivElement>(null);
142
148
  const activeTool = usePlayerStore((s) => s.activeTool);
143
149
  const [hoveredClip, setHoveredClip] = useState<string | null>(null);
144
150
  const isDragging = useRef(false);
145
- const [shiftHeld, setShiftHeld] = useState(false);
146
-
147
- useMountEffect(() => {
148
- const key = (e: KeyboardEvent) => e.key === "Shift" && setShiftHeld(e.type === "keydown");
149
- const blur = () => setShiftHeld(false);
150
- window.addEventListener("keydown", key);
151
- window.addEventListener("keyup", key);
152
- window.addEventListener("blur", blur);
153
- return () => {
154
- window.removeEventListener("keydown", key);
155
- window.removeEventListener("keyup", key);
156
- window.removeEventListener("blur", blur);
157
- };
158
- });
159
-
151
+ const shiftHeld = useTimelineShiftModifier();
160
152
  const [showPopover, setShowPopover] = useState(false);
161
153
  const [kfContextMenu, setKfContextMenu] = useState<KeyframeDiamondContextMenuState | null>(null);
162
154
  const [clipContextMenu, setClipContextMenu] = useState<{
@@ -164,34 +156,37 @@ export const Timeline = memo(function Timeline({
164
156
  y: number;
165
157
  element: TimelineElement;
166
158
  } | null>(null);
167
-
168
159
  const setContainerRef = useCallback((el: HTMLDivElement | null) => {
169
160
  containerRef.current = el;
170
161
  }, []);
171
-
172
- // Last horizontal scroll offset, restored across the post-edit iframe reload (pinned zoom).
173
162
  const lastScrollLeftRef = useRef(0);
174
-
175
- const effectiveDuration = useMemo(() => {
176
- const safeDur = Number.isFinite(duration) ? duration : 0;
177
- if (rawElements.length === 0) return safeDur;
178
- const result = Math.max(safeDur, ...rawElements.map((el) => el.start + el.duration));
179
- return Number.isFinite(result) ? result : safeDur;
180
- }, [rawElements, duration]);
181
-
163
+ const effectiveDuration = useMemo(
164
+ () => getEffectiveTimelineDuration(duration, rawElements),
165
+ [duration, rawElements],
166
+ );
182
167
  const keyframeCache = usePlayerStore((s) => s.keyframeCache);
183
168
  useAutoExpandKeyframedClips(gsapAnimations);
184
- const { tracks, trackStyles, trackOrder, trackOrderRef, laneCounts, rowHeights, rowHeightsRef } =
185
- useTimelineTrackLayout(expandedElements, gsapAnimations, selectedElementId, selectedElementIds);
169
+ const {
170
+ tracks,
171
+ trackStyles,
172
+ trackOrder,
173
+ trackOrderRef,
174
+ laneCounts,
175
+ rowGeometry,
176
+ rowGeometryRef,
177
+ } = useTimelineTrackLayout(
178
+ expandedElements,
179
+ gsapAnimations,
180
+ selectedElementId,
181
+ selectedElementIds,
182
+ );
186
183
  const expandedElementsRef = useRef(expandedElements);
187
184
  expandedElementsRef.current = expandedElements;
188
-
189
185
  const ppsRef = useRef(100);
190
186
  const durationRef = useRef(effectiveDuration);
191
187
  durationRef.current = effectiveDuration;
192
188
  // Declared before the fitPps derivation so the edit-pin wrappers can close over it.
193
189
  const fitPpsRef = useRef(100);
194
-
195
190
  const {
196
191
  pinZoomBeforeEdit,
197
192
  setRangeSelectionRef,
@@ -215,11 +210,9 @@ export const Timeline = memo(function Timeline({
215
210
  onBlockDrop,
216
211
  onCompositionDrop,
217
212
  });
218
-
219
213
  const { readClipZIndex, applyStackingPatches, zSyncEnabled } = useTimelineStackingSync({
220
214
  expandedElementsRef,
221
215
  });
222
-
223
216
  const {
224
217
  gapMenuModel,
225
218
  gapHighlight,
@@ -249,7 +242,7 @@ export const Timeline = memo(function Timeline({
249
242
  ppsRef,
250
243
  durationRef,
251
244
  trackOrderRef,
252
- rowHeightsRef,
245
+ rowGeometryRef,
253
246
  onMoveElement: pinnedOnMoveElement,
254
247
  onMoveElements: pinnedOnMoveElements,
255
248
  onResizeElement: pinnedOnResizeElement,
@@ -268,25 +261,41 @@ export const Timeline = memo(function Timeline({
268
261
  ppsRef,
269
262
  durationRef,
270
263
  trackOrderRef,
271
- rowHeightsRef,
264
+ rowGeometryRef,
272
265
  contentOrigin,
273
266
  onFileDrop: pinnedOnFileDrop,
274
267
  onAssetDrop: pinnedOnAssetDrop,
275
268
  onBlockDrop: pinnedOnBlockDrop,
276
269
  onCompositionDrop: pinnedOnCompositionDrop,
277
270
  });
278
-
279
- const displayLayout = useTimelineDisplayLayout(draggedClip, trackOrder, rowHeights);
271
+ const displayLayout = useTimelineDisplayLayout(draggedClip, trackOrder, rowGeometry);
280
272
  const { recordTimelineScroll } = useTimelinePerformanceTelemetry({
281
273
  totalClipCount: expandedElements.length,
282
274
  totalRowCount: displayLayout.displayTrackOrder.length,
283
275
  zoomMode,
284
276
  });
285
- const { viewportWidth, showShortcutHint, setScrollRef } = useTimelineScrollViewport(scrollRef, [
286
- timelineReady,
287
- expandedElements.length,
288
- displayLayout.totalH,
289
- ]);
277
+ const { viewport, showShortcutHint, setScrollRef, syncScrollViewport } =
278
+ useTimelineScrollViewport(scrollRef, [
279
+ timelineReady,
280
+ expandedElements.length,
281
+ displayLayout.totalH,
282
+ ]);
283
+ const rowWindow = useTimelineRowVirtualization({
284
+ scrollRef,
285
+ viewport,
286
+ rowGeometry: displayLayout.rowGeometry,
287
+ sessionEpoch,
288
+ elements: expandedElements,
289
+ selectedElementId,
290
+ revealElementId: clipRevealRequest?.elementId ?? null,
291
+ draggedRowKey: draggedClip?.started ? draggedClip.previewTrack : undefined,
292
+ resizingRowKey: resizingClip?.element.track,
293
+ clipContextMenuRowKey: clipContextMenu?.element.track,
294
+ keyframeContextMenuRowKey: kfContextMenu?.element.track,
295
+ lastScrollLeftRef,
296
+ syncScrollViewport,
297
+ });
298
+ const { enabled: rowVirtualizationActive, virtualRows, focusedElementId } = rowWindow;
290
299
  const selectedKeyframes = usePlayerStore((s) => s.selectedKeyframes);
291
300
  const toggleSelectedKeyframe = usePlayerStore((s) => s.toggleSelectedKeyframe);
292
301
  const { onClickKeyframe, onSelectSegment, onShiftClickKeyframe, onContextMenuKeyframe } =
@@ -299,39 +308,53 @@ export const Timeline = memo(function Timeline({
299
308
  setKfContextMenu,
300
309
  toggleSelectedKeyframe,
301
310
  });
302
-
303
- const selectedElement = useMemo(
304
- () =>
305
- expandedElements.find((element) => (element.key ?? element.id) === selectedElementId) ?? null,
306
- [expandedElements, selectedElementId],
307
- );
308
- const selectedElementRef = useRef<TimelineElement | null>(selectedElement);
309
- selectedElementRef.current = selectedElement;
310
-
311
- const {
312
- pps,
313
- fitPps,
314
- displayContentWidth,
315
- displayDuration,
316
- clipStateVersion,
317
- zoomModeRef,
318
- manualZoomPercentRef,
319
- } = useTimelineGeometry({
320
- viewportWidth,
321
- effectiveDuration,
322
- zoomMode,
323
- manualZoomPercent,
324
- ppsRef,
325
- fitPpsRef,
326
- draggedClip,
327
- resizingClip,
328
- expandedElements,
329
- isDragging,
330
- scrollRef,
331
- lastScrollLeftRef,
311
+ const { pps, fitPps, displayContentWidth, displayDuration, zoomModeRef, manualZoomPercentRef } =
312
+ useTimelineGeometry({
313
+ viewportWidth: viewport.clientWidth,
314
+ effectiveDuration,
315
+ zoomMode,
316
+ manualZoomPercent,
317
+ ppsRef,
318
+ fitPpsRef,
319
+ draggedClip,
320
+ resizingClip,
321
+ expandedElements,
322
+ isDragging,
323
+ scrollRef,
324
+ lastScrollLeftRef,
325
+ contentOrigin,
326
+ });
327
+ const { clipIndex, renderTimeRange, pinnedClipIdentities } = useTimelineClipRenderWindow({
328
+ tracks,
329
+ viewport,
330
+ pixelsPerSecond: pps,
332
331
  contentOrigin,
332
+ duration: displayDuration,
333
+ selectedElementId: selectedElementId ?? undefined,
334
+ draggedElementId: draggedClip ? getTimelineElementIdentity(draggedClip.element) : undefined,
335
+ resizingElementId: resizingClip ? getTimelineElementIdentity(resizingClip.element) : undefined,
336
+ revealElementId: clipRevealRequest?.elementId,
337
+ focusedEaseElementId: focusedEaseSegment?.elementId,
338
+ clipContextMenuElementId: clipContextMenu
339
+ ? getTimelineElementIdentity(clipContextMenu.element)
340
+ : undefined,
341
+ keyframeContextMenuElementId: kfContextMenu
342
+ ? getTimelineElementIdentity(kfContextMenu.element)
343
+ : undefined,
344
+ focusedElementId,
345
+ scrollRef,
346
+ elements: expandedElements,
347
+ rowGeometry: displayLayout.rowGeometry,
348
+ allowHorizontalReveal: zoomMode === "manual",
349
+ rowVirtualizationActive,
350
+ sessionEpoch,
351
+ });
352
+ useTimelineActiveClips({
353
+ scrollRef,
354
+ currentTime,
355
+ clipStateVersion: renderTimeRange,
356
+ elementStateVersion: expandedElements,
333
357
  });
334
-
335
358
  const laneGapStrips = useTimelineGapHighlights({
336
359
  gapHighlight,
337
360
  tracks,
@@ -364,11 +387,6 @@ export const Timeline = memo(function Timeline({
364
387
  onSeek,
365
388
  contentOrigin,
366
389
  });
367
- useTimelineActiveClips({
368
- scrollRef,
369
- currentTime,
370
- clipStateVersion,
371
- });
372
390
  const { razorGuideX, updateRazorGuide, clearRazorGuide, splitAllAtPointer } =
373
391
  useTimelineRazorInteraction({
374
392
  active: activeTool === "razor",
@@ -400,47 +418,25 @@ export const Timeline = memo(function Timeline({
400
418
  setShowPopover,
401
419
  elementsRef: expandedElementsRef,
402
420
  trackOrderRef,
403
- rowHeightsRef,
421
+ rowGeometryRef,
404
422
  onSelectElement,
405
423
  contentOrigin,
406
424
  });
407
425
  setRangeSelectionRef.current = setRangeSelection; // stable ref consumed by useTimelineClipDrag
408
426
 
409
- const prevSelectedRef = useRef(selectedElementRef.current);
410
- // eslint-disable-next-line no-restricted-syntax, react-hooks/exhaustive-deps
411
- useEffect(() => {
412
- const prev = prevSelectedRef.current;
413
- const curr = selectedElementRef.current;
414
- prevSelectedRef.current = curr;
415
- if (prev && !curr) {
416
- setShowPopover(false);
417
- setRangeSelection(null);
418
- }
419
- });
427
+ useTimelineSelectionLifecycle(expandedElements, selectedElementId, setShowPopover, () =>
428
+ setRangeSelection(null),
429
+ );
420
430
 
421
- // Frame display mode labels ruler ticks as frame numbers — pass the fps so ticks snap to frames.
422
- const tickFps = timeDisplayMode === "frame" ? STUDIO_PREVIEW_FPS : undefined;
423
- const { major, minor } = useMemo(
424
- () => generateTicks(displayDuration, pps, tickFps),
425
- [displayDuration, pps, tickFps],
431
+ const { major, minor, majorTickInterval } = useTimelineTicks(
432
+ displayDuration,
433
+ pps,
434
+ timeDisplayMode,
435
+ rowVirtualizationActive ? renderTimeRange : undefined,
426
436
  );
427
- const majorTickInterval = major.length >= 2 ? major[1] - major[0] : effectiveDuration;
428
437
 
429
438
  const getPreviewElement = useCallback(
430
- (element: TimelineElement): TimelineElement => {
431
- if (
432
- resizingClip &&
433
- (resizingClip.element.key ?? resizingClip.element.id) === (element.key ?? element.id)
434
- ) {
435
- return {
436
- ...element,
437
- start: resizingClip.previewStart,
438
- duration: resizingClip.previewDuration,
439
- playbackStart: resizingClip.previewPlaybackStart,
440
- };
441
- }
442
- return element;
443
- },
439
+ (element: TimelineElement): TimelineElement => getTimelinePreviewElement(element, resizingClip),
444
440
  [resizingClip],
445
441
  );
446
442
 
@@ -460,6 +456,7 @@ export const Timeline = memo(function Timeline({
460
456
  <div
461
457
  ref={setContainerRef}
462
458
  aria-label="Timeline"
459
+ data-timeline-element-count={expandedElements.length}
463
460
  className={`relative border-t select-none h-full overflow-hidden ${isDragOver ? "ring-1 ring-inset ring-studio-accent/60" : ""} ${activeTool === "razor" ? "cursor-crosshair" : shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
464
461
  onMouseMove={updateRazorGuide}
465
462
  onMouseLeave={clearRazorGuide}
@@ -471,12 +468,16 @@ export const Timeline = memo(function Timeline({
471
468
  >
472
469
  <div
473
470
  ref={setScrollRef}
471
+ data-timeline-scroll-viewport
472
+ data-timeline-auto-scroll-left-inset={labelMode ? LABEL_COL_W : 0}
474
473
  tabIndex={-1}
475
474
  className={`${zoomMode === "fit" ? "overflow-x-hidden" : "overflow-x-auto"} overflow-y-auto h-full outline-none`}
476
475
  onScroll={(e) => {
477
476
  lastScrollLeftRef.current = e.currentTarget.scrollLeft; // restored across post-edit reload
478
477
  recordTimelineScroll(e.currentTarget);
478
+ syncScrollViewport(e.currentTarget, true);
479
479
  }}
480
+ {...rowWindow.timelineFocusProps}
480
481
  onDragOver={handleAssetDragOver}
481
482
  onDragLeave={() => clearDropPreview()}
482
483
  onDrop={handleAssetDrop}
@@ -507,6 +508,12 @@ export const Timeline = memo(function Timeline({
507
508
  theme={theme}
508
509
  displayTrackOrder={displayLayout.displayTrackOrder}
509
510
  rowHeights={displayLayout.displayRowHeights}
511
+ rowGeometry={displayLayout.rowGeometry}
512
+ virtualRows={virtualRows}
513
+ rowsVirtualized={rowVirtualizationActive}
514
+ clipIndex={clipIndex}
515
+ renderTimeRange={renderTimeRange}
516
+ pinnedClipIdentities={pinnedClipIdentities}
510
517
  trackOrder={trackOrder}
511
518
  tracks={tracks}
512
519
  trackStyles={trackStyles}
@@ -520,7 +527,10 @@ export const Timeline = memo(function Timeline({
520
527
  blockedClipRef={blockedClipRef}
521
528
  suppressClickRef={suppressClickRef}
522
529
  scrollRef={scrollRef}
523
- renderClipContent={renderClipContent}
530
+ // Windowing drops content to mount a row cheaply; unvirtualized it is pure cost.
531
+ renderClipContent={
532
+ rowVirtualizationActive && viewport.isScrolling ? undefined : renderClipContent
533
+ }
524
534
  renderClipOverlay={renderClipOverlay}
525
535
  playheadRef={playheadRef}
526
536
  onDrillDown={onDrillDown}