@hyperframes/studio 0.7.86 → 0.7.87

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-BFqB5AWJ.js} +1 -1
  2. package/dist/assets/{index-B5QGai9B.js → index-B1Vf4vCl.js} +1 -1
  3. package/dist/assets/index-BXkzp_OU.css +1 -0
  4. package/dist/assets/{index-CISVI7UC.js → index-BblzZ6Av.js} +221 -221
  5. package/dist/assets/{index-C0Vr8jjc.js → index-D_JU4EeH.js} +1 -1
  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,5 +1,5 @@
1
- import { formatTime } from "../lib/time";
2
1
  import type { ZoomMode } from "../store/playerStore";
2
+ import type { TimelineTimeRange } from "../lib/timelineClipIndex";
3
3
 
4
4
  /* ── Layout constants ──────────────────────────────────────────────── */
5
5
  export const GUTTER = 32;
@@ -10,6 +10,47 @@ export const RULER_H = 24;
10
10
  export const CLIP_Y = 3;
11
11
  export const CLIP_HANDLE_W = 18;
12
12
 
13
+ export interface TimelineBeatEntry {
14
+ readonly index: number;
15
+ readonly time: number;
16
+ readonly strength: number | undefined;
17
+ }
18
+
19
+ function findFirstTimeAtOrAfter(times: readonly number[], target: number): number {
20
+ let low = 0;
21
+ let high = times.length;
22
+ while (low < high) {
23
+ const mid = Math.floor((low + high) / 2);
24
+ if ((times[mid] ?? Number.POSITIVE_INFINITY) < target) low = mid + 1;
25
+ else high = mid;
26
+ }
27
+ return low;
28
+ }
29
+
30
+ /** Slice sorted beat data without allocating entries outside the render window. */
31
+ export function getTimelineBeatEntries(
32
+ beatTimes: readonly number[] | undefined,
33
+ beatStrengths: readonly number[] | undefined,
34
+ range: TimelineTimeRange | undefined,
35
+ pinnedIndexes: ReadonlySet<number> = new Set(),
36
+ ): readonly TimelineBeatEntry[] {
37
+ if (!beatTimes?.length) return [];
38
+ const start = range?.start ?? Number.NEGATIVE_INFINITY;
39
+ const end = range?.end ?? Number.POSITIVE_INFINITY;
40
+ const selected = new Set<number>();
41
+ for (let index = findFirstTimeAtOrAfter(beatTimes, start); index < beatTimes.length; index++) {
42
+ const time = beatTimes[index];
43
+ if (time === undefined || time >= end) break;
44
+ selected.add(index);
45
+ }
46
+ for (const index of pinnedIndexes) {
47
+ if (index >= 0 && index < beatTimes.length) selected.add(index);
48
+ }
49
+ return [...selected]
50
+ .sort((left, right) => left - right)
51
+ .map((index) => ({ index, time: beatTimes[index]!, strength: beatStrengths?.[index] }));
52
+ }
53
+
13
54
  export function getTimelineLaneTop(laneIndex: number): number {
14
55
  return TRACK_H + Math.max(0, Math.trunc(laneIndex)) * LANE_H;
15
56
  }
@@ -74,55 +115,134 @@ function validRowHeight(height: number | undefined): number {
74
115
  return height;
75
116
  }
76
117
 
77
- /**
78
- * Memoized by the rowHeights array identity: a marquee/drag pointer tick calls
79
- * getTimelineRowTop once per clip, and each call would otherwise rebuild the
80
- * whole cumulative array (O(clips x rows) allocations per tick). rowHeights is
81
- * itself memoized upstream (useTimelineTrackLayout), so the identity is stable
82
- * for the life of a gesture. Callers must treat the result as read-only.
83
- */
84
- const rowOffsetsCache = new WeakMap<readonly number[], number[]>();
118
+ export interface TimelineRowGeometry {
119
+ readonly rowKeys: readonly number[];
120
+ readonly rowHeights: readonly number[];
121
+ /** Cumulative row boundaries, including the final bottom boundary. */
122
+ readonly rowOffsets: readonly number[];
123
+ readonly rowsHeight: number;
124
+ readonly canvasHeight: number;
125
+ getRowIndex(rowKey: number): number;
126
+ getRowHeight(row: number): number;
127
+ getRowTop(row: number): number;
128
+ getRowFromY(contentY: number): number;
129
+ getRowPositionFromY(contentY: number): {
130
+ rowFloat: number;
131
+ row: number;
132
+ fraction: number;
133
+ rowHeight: number;
134
+ };
135
+ }
136
+
137
+ const rowGeometryCache = new WeakMap<readonly number[], TimelineRowGeometry>();
138
+ const EMPTY_ROW_HEIGHTS: readonly number[] = Object.freeze([]);
139
+
140
+ /** Build the immutable row snapshot shared by rendering and hit testing. */
141
+ export function createTimelineRowGeometry(
142
+ rowKeys: readonly number[],
143
+ rowHeights: readonly number[],
144
+ ): TimelineRowGeometry {
145
+ const heights = Object.freeze(rowHeights.map(validRowHeight));
146
+ const keys = Object.freeze(
147
+ heights.map((_, row) => {
148
+ const key = rowKeys[row];
149
+ return key !== undefined && Number.isFinite(key) ? key : row;
150
+ }),
151
+ );
152
+ const offsets = [0];
153
+ for (const height of heights) offsets.push((offsets.at(-1) ?? 0) + height);
154
+ Object.freeze(offsets);
155
+ const rowIndexByKey = new Map(keys.map((key, row) => [key, row]));
156
+
157
+ const getRowHeight = (row: number) => validRowHeight(heights[row]);
158
+ const getRowOffset = (row: number) => {
159
+ if (heights.length === 0) return row * TRACK_H;
160
+ if (row <= 0) return row * getRowHeight(0);
161
+ if (row >= heights.length) {
162
+ return (offsets[heights.length] ?? 0) + (row - heights.length) * TRACK_H;
163
+ }
164
+ const wholeRow = Math.floor(row);
165
+ return (offsets[wholeRow] ?? 0) + (row - wholeRow) * getRowHeight(wholeRow);
166
+ };
167
+ const getRowFromY = (contentY: number) => {
168
+ const y = contentY - RULER_H - TRACKS_TOP_PAD;
169
+ if (heights.length === 0) return y / TRACK_H;
170
+ if (y < 0) return y / getRowHeight(0);
171
+ const rowsHeight = offsets[heights.length] ?? 0;
172
+ if (y >= rowsHeight) return heights.length + (y - rowsHeight) / TRACK_H;
173
+
174
+ // First boundary strictly greater than y. Unlike the old linear scan this
175
+ // stays logarithmic for large timelines and uses the precomputed offsets.
176
+ let low = 1;
177
+ let high = heights.length;
178
+ while (low < high) {
179
+ const mid = Math.floor((low + high) / 2);
180
+ if ((offsets[mid] ?? 0) > y) high = mid;
181
+ else low = mid + 1;
182
+ }
183
+ const row = low - 1;
184
+ return row + (y - (offsets[row] ?? 0)) / getRowHeight(row);
185
+ };
186
+ const geometry: TimelineRowGeometry = {
187
+ rowKeys: keys,
188
+ rowHeights: heights,
189
+ rowOffsets: offsets,
190
+ rowsHeight: offsets.at(-1) ?? 0,
191
+ canvasHeight: RULER_H + TRACKS_TOP_PAD + (offsets.at(-1) ?? 0) + TRACKS_BOTTOM_PAD,
192
+ getRowIndex: (rowKey) => rowIndexByKey.get(rowKey) ?? -1,
193
+ getRowHeight,
194
+ getRowTop: (row) => RULER_H + TRACKS_TOP_PAD + getRowOffset(row),
195
+ getRowFromY,
196
+ getRowPositionFromY: (contentY) => {
197
+ const rowFloat = getRowFromY(contentY);
198
+ const row = Math.floor(rowFloat);
199
+ return { rowFloat, row, fraction: rowFloat - row, rowHeight: getRowHeight(row) };
200
+ },
201
+ };
202
+ const frozenGeometry = Object.freeze(geometry);
203
+ rowGeometryCache.set(heights, frozenGeometry);
204
+ return frozenGeometry;
205
+ }
206
+
207
+ /** Compatibility accessor; repeated calls for one height-array reuse one snapshot. */
208
+ export function getTimelineRowGeometry(rowHeights: readonly number[]): TimelineRowGeometry {
209
+ const cached = rowGeometryCache.get(rowHeights);
210
+ if (cached) return cached;
211
+ const geometry = createTimelineRowGeometry(
212
+ rowHeights.map((_, row) => row),
213
+ rowHeights,
214
+ );
215
+ rowGeometryCache.set(rowHeights, geometry);
216
+ return geometry;
217
+ }
85
218
 
86
219
  /** Cumulative top offsets, including the final bottom boundary. */
87
220
  export function getTimelineRowOffsets(rowHeights: readonly number[]): number[] {
88
- const cached = rowOffsetsCache.get(rowHeights);
89
- if (cached) return cached;
90
- const offsets = [0];
91
- for (const height of rowHeights) {
92
- offsets.push((offsets[offsets.length - 1] ?? 0) + validRowHeight(height));
93
- }
94
- rowOffsetsCache.set(rowHeights, offsets);
95
- return offsets;
221
+ return [...getTimelineRowGeometry(rowHeights).rowOffsets];
96
222
  }
97
223
 
98
- export function getTimelineRowHeight(row: number, rowHeights: readonly number[] = []): number {
224
+ export function getTimelineRowHeight(
225
+ row: number,
226
+ rowHeights: readonly number[] = EMPTY_ROW_HEIGHTS,
227
+ ): number {
99
228
  return validRowHeight(rowHeights[row]);
100
229
  }
101
230
 
102
231
  function getTimelineRowOffset(row: number, rowHeights: readonly number[]): number {
103
- if (rowHeights.length === 0) return row * TRACK_H;
104
- const offsets = getTimelineRowOffsets(rowHeights);
105
- if (row <= 0) return row * getTimelineRowHeight(0, rowHeights);
106
- if (row >= rowHeights.length) {
107
- // Deliberately TRACK_H, not the last row's height: rows past the end do not
108
- // exist yet, and a row created by dropping there starts unexpanded. The
109
- // pre-first-row branch above uses row 0's concrete height instead because
110
- // that row DOES exist — the pointer is in the top pad above a real lane.
111
- return (offsets[rowHeights.length] ?? 0) + (row - rowHeights.length) * TRACK_H;
112
- }
113
- const wholeRow = Math.floor(row);
114
- const fraction = row - wholeRow;
115
- return (offsets[wholeRow] ?? 0) + fraction * getTimelineRowHeight(wholeRow, rowHeights);
232
+ return getTimelineRowGeometry(rowHeights).getRowTop(row) - RULER_H - TRACKS_TOP_PAD;
116
233
  }
117
234
 
118
235
  /**
119
236
  * The y (content-space) of the top edge of track ROW index `row` (0 = first
120
- * displayed lane). The single source of truth for rowy the ruler height plus
237
+ * displayed lane). The single source of truth for row->y: the ruler height plus
121
238
  * the top breathing pad plus whole track lanes above it. Every clip/ghost/
122
- * placeholder/insertion top and every pointer-yrow inversion goes through this
239
+ * placeholder/insertion top and every pointer-y->row inversion goes through this
123
240
  * (or its inverse in {@link getTimelineRowFromY}) so the pad can never drift.
124
241
  */
125
- export function getTimelineRowTop(row: number, rowHeights: readonly number[] = []): number {
242
+ export function getTimelineRowTop(
243
+ row: number,
244
+ rowHeights: readonly number[] = EMPTY_ROW_HEIGHTS,
245
+ ): number {
126
246
  return RULER_H + TRACKS_TOP_PAD + getTimelineRowOffset(row, rowHeights);
127
247
  }
128
248
 
@@ -131,34 +251,18 @@ export function getTimelineRowTop(row: number, rowHeights: readonly number[] = [
131
251
  * space y (used for insert-row / drop-lane decisions). Locates the concrete row
132
252
  * from cumulative offsets, then returns its local fractional position.
133
253
  */
134
- export function getTimelineRowFromY(contentY: number, rowHeights: readonly number[] = []): number {
135
- const y = contentY - RULER_H - TRACKS_TOP_PAD;
136
- if (rowHeights.length === 0) return y / TRACK_H;
137
- if (y < 0) return y / getTimelineRowHeight(0, rowHeights);
138
-
139
- const offsets = getTimelineRowOffsets(rowHeights);
140
- for (let row = 0; row < rowHeights.length; row += 1) {
141
- const bottom = offsets[row + 1] ?? 0;
142
- if (y < bottom) {
143
- const top = offsets[row] ?? 0;
144
- return row + (y - top) / getTimelineRowHeight(row, rowHeights);
145
- }
146
- }
147
- return rowHeights.length + (y - (offsets[rowHeights.length] ?? 0)) / TRACK_H;
254
+ export function getTimelineRowFromY(
255
+ contentY: number,
256
+ rowHeights: readonly number[] = EMPTY_ROW_HEIGHTS,
257
+ ): number {
258
+ return getTimelineRowGeometry(rowHeights).getRowFromY(contentY);
148
259
  }
149
260
 
150
261
  export function getTimelineRowPositionFromY(
151
262
  contentY: number,
152
- rowHeights: readonly number[] = [],
263
+ rowHeights: readonly number[] = EMPTY_ROW_HEIGHTS,
153
264
  ): { rowFloat: number; row: number; fraction: number; rowHeight: number } {
154
- const rowFloat = getTimelineRowFromY(contentY, rowHeights);
155
- const row = Math.floor(rowFloat);
156
- return {
157
- rowFloat,
158
- row,
159
- fraction: rowFloat - row,
160
- rowHeight: getTimelineRowHeight(row, rowHeights),
161
- };
265
+ return getTimelineRowGeometry(rowHeights).getRowPositionFromY(contentY);
162
266
  }
163
267
 
164
268
  /** Fractional insert band for the concrete row under a pointer. */
@@ -193,123 +297,6 @@ export const MIN_TIMELINE_EXTENT_S = 60;
193
297
  export const FIT_ZOOM_HEADROOM = 1.2;
194
298
 
195
299
  /* ── Tick generation ──────────────────────────────────────────────── */
196
- // fallow-ignore-next-line complexity
197
- function getMajorTickInterval(
198
- duration: number,
199
- pixelsPerSecond?: number,
200
- frameRate?: number,
201
- ): number {
202
- // "Nice" NLE steps: 1-2-5 sub-second decades, then 1s/2s/5s/10s/15s/30s,
203
- // minute multiples, and 15m/30m/1h so ultra-zoomed-out long comps still get
204
- // readable (non-colliding) labels instead of the old 10m fallback everywhere.
205
- const zoomIntervals = [
206
- 0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600,
207
- ];
208
- let interval: number;
209
- if (Number.isFinite(pixelsPerSecond) && (pixelsPerSecond ?? 0) > 0) {
210
- const targetMajorPx = 88;
211
- interval =
212
- zoomIntervals.find((candidate) => candidate * (pixelsPerSecond ?? 0) >= targetMajorPx) ??
213
- 3600;
214
- } else {
215
- const durationIntervals = [0.25, 0.5, 1, 2, 5, 10, 15, 30, 60];
216
- const target = duration / 6;
217
- interval = durationIntervals.find((candidate) => candidate >= target) ?? 60;
218
- }
219
- // Frame display mode: labels are frame numbers, so a major step must be a
220
- // WHOLE number of frames — sub-frame steps produce duplicate/uneven labels
221
- // (e.g. 0.02s at 30fps is 0.6 frames → "0, 1, 1, 2, 2…"). Snap UP (ceil) so
222
- // the label spacing never drops below the readability target.
223
- if (Number.isFinite(frameRate) && (frameRate ?? 0) > 0) {
224
- const fps = frameRate ?? 0;
225
- return Math.max(1, Math.ceil(interval * fps - 1e-6)) / fps;
226
- }
227
- return interval;
228
- }
229
-
230
- // How many equal parts to split each major interval into for minor ticks. Prefer
231
- // quarters (4) so the midpoint stays a minor tick; fall back to halves (2) then
232
- // none (0) as ticks get too dense to read (< ~8px apart). In frame display mode
233
- // the subdivision must also keep minor ticks on WHOLE frames (a minor tick at a
234
- // sub-frame time is not a seekable position), so only divisors of the major
235
- // step's frame count qualify — quarters, then fifths (15/30-frame majors),
236
- // thirds, halves.
237
- // fallow-ignore-next-line complexity
238
- function getMinorSubdivisions(
239
- majorInterval: number,
240
- pixelsPerSecond?: number,
241
- frameRate?: number,
242
- ): number {
243
- const pps = Number.isFinite(pixelsPerSecond) ? (pixelsPerSecond ?? 0) : 0;
244
- if (pps <= 0) return 4; // no zoom info (duration-fit mode): quarter ticks
245
- const fps = Number.isFinite(frameRate) ? (frameRate ?? 0) : 0;
246
- const majorFrames = fps > 0 ? Math.round(majorInterval * fps) : 0;
247
- const candidates = fps > 0 ? [4, 5, 3, 2] : [4, 2];
248
- for (const parts of candidates) {
249
- if (fps > 0 && majorFrames % parts !== 0) continue;
250
- if ((majorInterval / parts) * pps >= 8) return parts;
251
- }
252
- return 0;
253
- }
254
-
255
- // Ticks are exact multiples of the interval (multiplied per index, never
256
- // accumulated with `+=`, so long rulers don't drift), then rounded to 1µs to
257
- // keep values/keys clean without disturbing frame-exact positions like 2/30s.
258
- function roundTickValue(t: number): number {
259
- return Math.round(t * 1e6) / 1e6;
260
- }
261
-
262
- export function generateTicks(
263
- duration: number,
264
- pixelsPerSecond?: number,
265
- frameRate?: number,
266
- ): { major: number[]; minor: number[] } {
267
- if (duration <= 0 || !Number.isFinite(duration) || duration > 14400)
268
- return { major: [], minor: [] };
269
- const majorInterval = getMajorTickInterval(duration, pixelsPerSecond, frameRate);
270
- const subdivisions = getMinorSubdivisions(majorInterval, pixelsPerSecond, frameRate);
271
- const minorInterval = subdivisions > 0 ? majorInterval / subdivisions : 0;
272
- const major: number[] = [];
273
- const minor: number[] = [];
274
- const maxTicks = 2000; // Safety cap to prevent runaway tick generation
275
- for (let i = 0; major.length < maxTicks; i++) {
276
- const t = i * majorInterval;
277
- if (t > duration + 0.001) break;
278
- major.push(roundTickValue(t));
279
- // Emit the (subdivisions - 1) minor ticks between this major and the next.
280
- for (let k = 1; k < subdivisions && major.length + minor.length < maxTicks; k++) {
281
- const m = t + k * minorInterval;
282
- if (m <= duration + 0.001) minor.push(roundTickValue(m));
283
- }
284
- }
285
- return { major, minor };
286
- }
287
-
288
- export function formatTimelineTickLabel(time: number, duration: number, majorInterval: number) {
289
- if (!Number.isFinite(time)) return "00:00";
290
- const safeTime = Math.max(0, time);
291
- if (majorInterval < 0.1) {
292
- const totalHundredths = Math.round(safeTime * 100);
293
- const wholeSeconds = Math.floor(totalHundredths / 100);
294
- const hundredth = totalHundredths % 100;
295
- return `${formatTime(wholeSeconds)}.${hundredth.toString().padStart(2, "0")}`;
296
- }
297
- if (majorInterval < 1) {
298
- const totalTenths = Math.round(safeTime * 10);
299
- const wholeSeconds = Math.floor(totalTenths / 10);
300
- const tenth = totalTenths % 10;
301
- return `${formatTime(wholeSeconds)}.${tenth}`;
302
- }
303
- if (duration >= 3600 || safeTime >= 3600) {
304
- const totalSeconds = Math.floor(safeTime);
305
- const hours = Math.floor(totalSeconds / 3600);
306
- const minutes = Math.floor((totalSeconds % 3600) / 60);
307
- const seconds = totalSeconds % 60;
308
- return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
309
- }
310
- return formatTime(safeTime);
311
- }
312
-
313
300
  /* ── Width / duration derivation ──────────────────────────────────── */
314
301
  /**
315
302
  * Fit-mode pixels-per-second: fill the viewport with the composition plus
@@ -473,13 +460,52 @@ export function getTimelineScrubTime(input: {
473
460
  });
474
461
  return Math.max(0, Math.min(duration, x / pixelsPerSecond));
475
462
  }
463
+ const PLAYBACK_FOLLOW_POSITION = 0.75;
464
+
465
+ /**
466
+ * Keep a playing timeline calm until the playhead crosses the right-side
467
+ * comfort line, then advance the viewport just enough to hold it there. A
468
+ * loop/reverse jump that leaves the playhead behind the sticky labels restores
469
+ * the matching earlier viewport instead.
470
+ */
471
+ export function getTimelinePlaybackFollowScrollLeft(input: {
472
+ playheadX: number;
473
+ currentScrollLeft: number;
474
+ viewportWidth: number;
475
+ contentOrigin: number;
476
+ maxScrollLeft: number;
477
+ }): number {
478
+ const current = Number.isFinite(input.currentScrollLeft)
479
+ ? Math.max(0, input.currentScrollLeft)
480
+ : 0;
481
+ const max = Number.isFinite(input.maxScrollLeft) ? Math.max(0, input.maxScrollLeft) : 0;
482
+ const visibleTimelineWidth = input.viewportWidth - input.contentOrigin;
483
+ if (
484
+ !Number.isFinite(input.playheadX) ||
485
+ !Number.isFinite(input.contentOrigin) ||
486
+ !Number.isFinite(visibleTimelineWidth) ||
487
+ visibleTimelineWidth <= 0 ||
488
+ max <= 0
489
+ ) {
490
+ return Math.min(max, current);
491
+ }
492
+
493
+ const visibleStart = current + input.contentOrigin;
494
+ const followLine = visibleStart + visibleTimelineWidth * PLAYBACK_FOLLOW_POSITION;
495
+ let next = current;
496
+ if (input.playheadX > followLine) {
497
+ next = input.playheadX - input.contentOrigin - visibleTimelineWidth * PLAYBACK_FOLLOW_POSITION;
498
+ } else if (input.playheadX < visibleStart) {
499
+ next = input.playheadX - input.contentOrigin;
500
+ }
501
+ return Math.max(0, Math.min(max, next));
502
+ }
476
503
 
477
504
  export function getTimelineCanvasHeight(rowHeights: readonly number[]): number {
478
505
  // RULER_H + top pad + lanes + bottom pad. The old TIMELINE_SCROLL_BUFFER is
479
506
  // subsumed by TRACKS_BOTTOM_PAD (which is larger), so the drag-into-void space
480
507
  // below the last lane is real scrollable surface, not a hidden buffer.
481
- const rowsHeight = getTimelineRowOffsets(rowHeights).at(-1) ?? 0;
482
- return RULER_H + TRACKS_TOP_PAD + rowsHeight + TRACKS_BOTTOM_PAD;
508
+ return getTimelineRowGeometry(rowHeights).canvasHeight;
483
509
  }
484
510
 
485
511
  /* ── UI helpers ───────────────────────────────────────────────────── */
@@ -0,0 +1,23 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+
3
+ afterEach(() => {
4
+ vi.unstubAllEnvs();
5
+ vi.resetModules();
6
+ });
7
+
8
+ describe("timeline row virtualization flag", () => {
9
+ it("enables virtualization by default", async () => {
10
+ const { STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED } =
11
+ await import("./timelineRowVirtualizationFlag");
12
+
13
+ expect(STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED).toBe(true);
14
+ });
15
+
16
+ it("keeps an explicit rollback path", async () => {
17
+ vi.stubEnv("VITE_STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED", "0");
18
+ const { STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED } =
19
+ await import("./timelineRowVirtualizationFlag");
20
+
21
+ expect(STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED).toBe(false);
22
+ });
23
+ });
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Row virtualization is the product default. Setting the environment flag to
3
+ * "0" keeps one explicit rollback path for comparisons and emergencies.
4
+ *
5
+ * It lives in its own module so the scroll-viewport hook can read it without
6
+ * importing the virtualization hook that already imports the viewport snapshot
7
+ * type back, which would close an import cycle.
8
+ */
9
+ export const STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED =
10
+ import.meta.env.VITE_STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED !== "0";
@@ -0,0 +1,142 @@
1
+ import { formatTime } from "../lib/time";
2
+ import type { TimelineTimeRange } from "../lib/timelineClipIndex";
3
+
4
+ // fallow-ignore-next-line complexity
5
+ export function getTimelineMajorTickInterval(
6
+ duration: number,
7
+ pixelsPerSecond?: number,
8
+ frameRate?: number,
9
+ ): number {
10
+ // "Nice" NLE steps: 1-2-5 sub-second decades, then 1s/2s/5s/10s/15s/30s,
11
+ // minute multiples, and 15m/30m/1h so ultra-zoomed-out long comps still get
12
+ // readable (non-colliding) labels instead of the old 10m fallback everywhere.
13
+ const zoomIntervals = [
14
+ 0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600,
15
+ ];
16
+ let interval: number;
17
+ if (Number.isFinite(pixelsPerSecond) && (pixelsPerSecond ?? 0) > 0) {
18
+ const targetMajorPx = 88;
19
+ interval =
20
+ zoomIntervals.find((candidate) => candidate * (pixelsPerSecond ?? 0) >= targetMajorPx) ??
21
+ 3600;
22
+ } else {
23
+ const durationIntervals = [0.25, 0.5, 1, 2, 5, 10, 15, 30, 60];
24
+ const target = duration / 6;
25
+ interval = durationIntervals.find((candidate) => candidate >= target) ?? 60;
26
+ }
27
+ // Frame display mode: labels are frame numbers, so a major step must be a
28
+ // WHOLE number of frames. Snap UP so label spacing stays readable.
29
+ if (Number.isFinite(frameRate) && (frameRate ?? 0) > 0) {
30
+ const fps = frameRate ?? 0;
31
+ return Math.max(1, Math.ceil(interval * fps - 1e-6)) / fps;
32
+ }
33
+ return interval;
34
+ }
35
+
36
+ // Prefer quarter subdivisions so the midpoint remains visible; fall back to
37
+ // smaller whole-frame-compatible sets as ticks become too dense to read.
38
+ // fallow-ignore-next-line complexity
39
+ function getMinorSubdivisions(
40
+ majorInterval: number,
41
+ pixelsPerSecond?: number,
42
+ frameRate?: number,
43
+ ): number {
44
+ const pps = Number.isFinite(pixelsPerSecond) ? (pixelsPerSecond ?? 0) : 0;
45
+ if (pps <= 0) return 4;
46
+ const fps = Number.isFinite(frameRate) ? (frameRate ?? 0) : 0;
47
+ const majorFrames = fps > 0 ? Math.round(majorInterval * fps) : 0;
48
+ const candidates = fps > 0 ? [4, 5, 3, 2] : [4, 2];
49
+ for (const parts of candidates) {
50
+ if (fps > 0 && majorFrames % parts !== 0) continue;
51
+ if ((majorInterval / parts) * pps >= 8) return parts;
52
+ }
53
+ return 0;
54
+ }
55
+
56
+ // Multiply from the index rather than accumulating, then round to 1µs so long
57
+ // rulers do not drift and frame-exact positions keep clean values and keys.
58
+ function roundTickValue(time: number): number {
59
+ return Math.round(time * 1e6) / 1e6;
60
+ }
61
+
62
+ function isSupportedTickDuration(duration: number): boolean {
63
+ return duration > 0 && Number.isFinite(duration) && duration <= 14400;
64
+ }
65
+
66
+ function getTickRange(duration: number, range?: TimelineTimeRange): TimelineTimeRange {
67
+ return {
68
+ start: Math.max(0, range?.start ?? 0),
69
+ end: Math.min(duration, range?.end ?? duration),
70
+ };
71
+ }
72
+
73
+ function appendMinorTicks(
74
+ minor: number[],
75
+ majorTime: number,
76
+ minorInterval: number,
77
+ subdivisions: number,
78
+ range: TimelineTimeRange,
79
+ maxTicks: number,
80
+ majorCount: number,
81
+ ): void {
82
+ for (let part = 1; part < subdivisions && majorCount + minor.length < maxTicks; part++) {
83
+ const time = majorTime + part * minorInterval;
84
+ if (time >= range.start - 0.001 && time <= range.end + 0.001) {
85
+ minor.push(roundTickValue(time));
86
+ }
87
+ }
88
+ }
89
+
90
+ export function generateTicks(
91
+ duration: number,
92
+ pixelsPerSecond?: number,
93
+ frameRate?: number,
94
+ range?: TimelineTimeRange,
95
+ ): { major: number[]; minor: number[] } {
96
+ if (!isSupportedTickDuration(duration)) return { major: [], minor: [] };
97
+ const majorInterval = getTimelineMajorTickInterval(duration, pixelsPerSecond, frameRate);
98
+ const subdivisions = getMinorSubdivisions(majorInterval, pixelsPerSecond, frameRate);
99
+ const minorInterval = subdivisions > 0 ? majorInterval / subdivisions : 0;
100
+ const major: number[] = [];
101
+ const minor: number[] = [];
102
+ // Safety cap prevents malformed inputs from creating an unbounded ruler.
103
+ const maxTicks = 2000;
104
+ const tickRange = getTickRange(duration, range);
105
+ const firstMajorIndex = Math.max(0, Math.floor(tickRange.start / majorInterval));
106
+ for (let index = firstMajorIndex; major.length < maxTicks; index++) {
107
+ const time = index * majorInterval;
108
+ if (time > tickRange.end + 0.001) break;
109
+ if (time >= tickRange.start - 0.001) major.push(roundTickValue(time));
110
+ appendMinorTicks(minor, time, minorInterval, subdivisions, tickRange, maxTicks, major.length);
111
+ }
112
+ return { major, minor };
113
+ }
114
+
115
+ export function formatTimelineTickLabel(
116
+ time: number,
117
+ duration: number,
118
+ majorInterval: number,
119
+ ): string {
120
+ if (!Number.isFinite(time)) return "00:00";
121
+ const safeTime = Math.max(0, time);
122
+ if (majorInterval < 0.1) {
123
+ const totalHundredths = Math.round(safeTime * 100);
124
+ const wholeSeconds = Math.floor(totalHundredths / 100);
125
+ const hundredth = totalHundredths % 100;
126
+ return `${formatTime(wholeSeconds)}.${hundredth.toString().padStart(2, "0")}`;
127
+ }
128
+ if (majorInterval < 1) {
129
+ const totalTenths = Math.round(safeTime * 10);
130
+ const wholeSeconds = Math.floor(totalTenths / 10);
131
+ const tenth = totalTenths % 10;
132
+ return `${formatTime(wholeSeconds)}.${tenth}`;
133
+ }
134
+ if (duration >= 3600 || safeTime >= 3600) {
135
+ const totalSeconds = Math.floor(safeTime);
136
+ const hours = Math.floor(totalSeconds / 3600);
137
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
138
+ const seconds = totalSeconds % 60;
139
+ return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
140
+ }
141
+ return formatTime(safeTime);
142
+ }
@@ -0,0 +1,44 @@
1
+ import type { TimelineElement } from "../store/playerStore";
2
+ import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
3
+ import type { ResizingClipState } from "./timelineClipDragTypes";
4
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
5
+ import { animationContributesLane } from "./TimelinePropertyLanes";
6
+
7
+ export function hasKeyframedTimelineClips(
8
+ animationsByElement: ReadonlyMap<string, readonly GsapAnimation[]>,
9
+ ): boolean {
10
+ return Array.from(animationsByElement.values()).some((animations) =>
11
+ animations.some(animationContributesLane),
12
+ );
13
+ }
14
+
15
+ export function getEffectiveTimelineDuration(
16
+ duration: number,
17
+ elements: readonly TimelineElement[],
18
+ ): number {
19
+ const safeDuration = Number.isFinite(duration) ? duration : 0;
20
+ if (elements.length === 0) return safeDuration;
21
+ const result = Math.max(
22
+ safeDuration,
23
+ ...elements.map((element) => element.start + element.duration),
24
+ );
25
+ return Number.isFinite(result) ? result : safeDuration;
26
+ }
27
+
28
+ export function getTimelinePreviewElement(
29
+ element: TimelineElement,
30
+ resizingClip: ResizingClipState | null,
31
+ ): TimelineElement {
32
+ if (
33
+ resizingClip &&
34
+ getTimelineElementIdentity(resizingClip.element) === getTimelineElementIdentity(element)
35
+ ) {
36
+ return {
37
+ ...element,
38
+ start: resizingClip.previewStart,
39
+ duration: resizingClip.previewDuration,
40
+ playbackStart: resizingClip.previewPlaybackStart,
41
+ };
42
+ }
43
+ return element;
44
+ }