@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.
- package/dist/assets/{hyperframes-player-BL3qfGEQ.js → hyperframes-player-BFqB5AWJ.js} +1 -1
- package/dist/assets/{index-B5QGai9B.js → index-B1Vf4vCl.js} +1 -1
- package/dist/assets/index-BXkzp_OU.css +1 -0
- package/dist/assets/{index-CISVI7UC.js → index-BblzZ6Av.js} +221 -221
- package/dist/assets/{index-C0Vr8jjc.js → index-D_JU4EeH.js} +1 -1
- package/dist/index.d.ts +14 -6
- package/dist/index.html +2 -2
- package/dist/index.js +6265 -4922
- package/dist/index.js.map +1 -1
- package/package.json +11 -8
- package/src/components/TimelineToolbar.tsx +14 -5
- package/src/components/nle/NLEContext.tsx +4 -1
- package/src/components/nle/TimelinePane.tsx +2 -0
- package/src/hooks/studioTestMode.test.ts +35 -0
- package/src/hooks/studioTestMode.ts +23 -0
- package/src/hooks/useGestureRecording.test.tsx +120 -0
- package/src/hooks/useGestureRecording.ts +49 -40
- package/src/hooks/useStudioTestHooks.test.tsx +161 -0
- package/src/hooks/useStudioTestHooks.ts +54 -7
- package/src/player/components/BeatStrip.tsx +21 -5
- package/src/player/components/CompositionThumbnail.test.ts +96 -2
- package/src/player/components/CompositionThumbnail.tsx +29 -16
- package/src/player/components/Timeline.test.ts +104 -0
- package/src/player/components/Timeline.tsx +141 -131
- package/src/player/components/Timeline.virtualization.test.tsx +400 -0
- package/src/player/components/TimelineCanvas.tsx +18 -10
- package/src/player/components/TimelineClip.tsx +4 -0
- package/src/player/components/TimelineClipDiamonds.test.tsx +85 -5
- package/src/player/components/TimelineClipDiamonds.tsx +53 -13
- package/src/player/components/TimelineLanes.test.tsx +15 -4
- package/src/player/components/TimelineLanes.tsx +81 -26
- package/src/player/components/TimelinePropertyLanes.test.tsx +16 -20
- package/src/player/components/TimelinePropertyLanes.tsx +1 -0
- package/src/player/components/TimelineRuler.tsx +21 -5
- package/src/player/components/TimelineTrackRow.tsx +46 -0
- package/src/player/components/TimelineTypes.ts +2 -0
- package/src/player/components/timelineClipDragGestureLifecycle.test.ts +97 -0
- package/src/player/components/timelineClipDragGestureLifecycle.ts +261 -0
- package/src/player/components/timelineDiamondTypes.ts +5 -0
- package/src/player/components/timelineDragDrop.ts +5 -5
- package/src/player/components/timelineEditing.test.ts +32 -0
- package/src/player/components/timelineEditing.ts +47 -5
- package/src/player/components/timelineFocusIdentity.test.ts +21 -0
- package/src/player/components/timelineFocusIdentity.ts +21 -0
- package/src/player/components/timelineLaneProps.ts +9 -0
- package/src/player/components/timelineLayout.test.ts +59 -0
- package/src/player/components/timelineLayout.ts +202 -176
- package/src/player/components/timelineRowVirtualizationFlag.test.ts +23 -0
- package/src/player/components/timelineRowVirtualizationFlag.ts +10 -0
- package/src/player/components/timelineRulerGeometry.ts +142 -0
- package/src/player/components/timelineViewModel.ts +44 -0
- package/src/player/components/timelineViewportGeometry.ts +51 -0
- package/src/player/components/timelineZoom.test.ts +42 -28
- package/src/player/components/timelineZoom.ts +42 -25
- package/src/player/components/useTimelineActiveClips.test.ts +82 -79
- package/src/player/components/useTimelineActiveClips.ts +49 -55
- package/src/player/components/useTimelineClipDrag.ts +40 -230
- package/src/player/components/useTimelineClipRenderWindow.ts +95 -0
- package/src/player/components/useTimelineGeometry.ts +4 -11
- package/src/player/components/useTimelinePlayhead.ts +30 -14
- package/src/player/components/useTimelineRangeSelection.ts +5 -4
- package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +3 -2
- package/src/player/components/useTimelineRevealClip.test.tsx +139 -0
- package/src/player/components/useTimelineRevealClip.ts +171 -47
- package/src/player/components/useTimelineRowVirtualization.ts +140 -0
- package/src/player/components/useTimelineScrollViewport.test.tsx +202 -0
- package/src/player/components/useTimelineScrollViewport.ts +87 -9
- package/src/player/components/useTimelineSelectionLifecycle.ts +29 -0
- package/src/player/components/useTimelineShiftModifier.ts +20 -0
- package/src/player/components/useTimelineTicks.ts +22 -0
- package/src/player/components/useTimelineTrackLayout.test.ts +32 -26
- package/src/player/components/useTimelineTrackLayout.ts +35 -18
- package/src/player/components/useTimelineVirtualRows.test.tsx +174 -0
- package/src/player/components/useTimelineVirtualRows.ts +126 -0
- package/src/player/hooks/timelinePlayerSync.ts +20 -0
- package/src/player/hooks/useTimelinePlayer.seek.test.ts +90 -51
- package/src/player/hooks/useTimelinePlayer.ts +8 -20
- package/src/player/lib/playbackScrub.ts +2 -2
- package/src/player/lib/timelineClipIndex.test.ts +169 -0
- package/src/player/lib/timelineClipIndex.ts +208 -0
- package/src/player/lib/timelineElementIndexes.test.ts +31 -0
- package/src/player/lib/timelineElementIndexes.ts +44 -0
- package/src/player/lib/timelinePerformanceDiagnostics.test.ts +100 -0
- package/src/player/lib/timelinePerformanceDiagnostics.ts +120 -0
- package/src/player/lib/timelinePerformanceFixture.ts +167 -0
- package/src/player/lib/timelineViewportBudgets.test.ts +60 -0
- package/src/player/lib/timelineViewportBudgets.ts +151 -0
- package/src/player/store/playerStore.test.ts +20 -3
- package/src/player/store/playerStore.ts +73 -45
- package/src/utils/studioHelpers.test.ts +48 -1
- package/src/utils/studioHelpers.ts +31 -34
- package/src/utils/studioUiPreferences.ts +3 -1
- 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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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 row
|
|
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-y
|
|
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(
|
|
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(
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|