@hyperframes/studio 0.7.75 → 0.7.77

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 (134) hide show
  1. package/dist/assets/{hyperframes-player-BrfzYqX3.js → hyperframes-player-CEggaaxR.js} +1 -1
  2. package/dist/assets/{index-BvGKYVrH.js → index-B1INQNmj.js} +1 -1
  3. package/dist/assets/{index-CjGZkNxS.js → index-B1Tjjdse.js} +1 -1
  4. package/dist/assets/index-Bp4jAYZG.js +428 -0
  5. package/dist/assets/index-CBAfprvx.css +1 -0
  6. package/dist/index.d.ts +64 -17
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +18040 -16293
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/App.tsx +16 -17
  12. package/src/components/EditorShell.tsx +3 -0
  13. package/src/components/TimelineToolbar.test.tsx +48 -3
  14. package/src/components/TimelineToolbar.tsx +89 -36
  15. package/src/components/editor/AnimationCard.test.tsx +18 -75
  16. package/src/components/editor/AnimationCard.tsx +26 -2
  17. package/src/components/editor/GsapAddAnimationControl.tsx +68 -0
  18. package/src/components/editor/GsapAnimationSection.tsx +18 -157
  19. package/src/components/editor/KeyframeEaseList.tsx +5 -1
  20. package/src/components/editor/KeyframeNavigation.tsx +46 -14
  21. package/src/components/editor/KeyframeNavigationDiamond.test.tsx +59 -0
  22. package/src/components/editor/MotionPathOverlay.tsx +32 -10
  23. package/src/components/editor/PropertyPanel.tsx +15 -5
  24. package/src/components/editor/PropertyPanelFlat.tsx +48 -23
  25. package/src/components/editor/gsapAnimationCallbacks.test.ts +122 -0
  26. package/src/components/editor/gsapAnimationCallbacks.ts +108 -4
  27. package/src/components/editor/keyframeRetime.test.ts +25 -22
  28. package/src/components/editor/keyframeRetime.ts +1 -2
  29. package/src/components/editor/propertyPanelFlatLayoutSection.tsx +2 -2
  30. package/src/components/editor/propertyPanelFlatMotionSection.tsx +25 -139
  31. package/src/components/editor/propertyPanelFlatTextSection.test.tsx +32 -16
  32. package/src/components/editor/propertyPanelFlatTextSection.tsx +21 -4
  33. package/src/components/nle/PreviewOverlays.tsx +3 -1
  34. package/src/components/nle/useTimelineEditCallbacks.test.ts +30 -0
  35. package/src/components/nle/useTimelineEditCallbacks.test.tsx +693 -0
  36. package/src/components/nle/useTimelineEditCallbacks.ts +197 -57
  37. package/src/contexts/TimelineEditContext.tsx +1 -1
  38. package/src/hooks/deleteSelectedKeyframes.ts +24 -8
  39. package/src/hooks/gsapDragCommit.ts +2 -2
  40. package/src/hooks/gsapDragPositionCommit.ts +84 -5
  41. package/src/hooks/gsapKeyframeCacheHelpers.test.ts +176 -1
  42. package/src/hooks/gsapKeyframeCacheHelpers.ts +93 -52
  43. package/src/hooks/gsapRuntimeBridge.test.ts +100 -0
  44. package/src/hooks/gsapScriptCommitHelpers.ts +3 -2
  45. package/src/hooks/gsapScriptCommitTypes.ts +2 -0
  46. package/src/hooks/gsapShared.test.ts +109 -1
  47. package/src/hooks/gsapShared.ts +142 -17
  48. package/src/hooks/gsapTweenSynth.test.ts +30 -1
  49. package/src/hooks/gsapTweenSynth.ts +61 -14
  50. package/src/hooks/keyframeCacheAstLoad.ts +181 -0
  51. package/src/hooks/timelineEditingHelpers.test.ts +80 -0
  52. package/src/hooks/timelineMoveAdapter.ts +9 -2
  53. package/src/hooks/useDomEditWiring.ts +2 -2
  54. package/src/hooks/useDomSelection.ts +4 -0
  55. package/src/hooks/useEnableKeyframes.test.ts +122 -0
  56. package/src/hooks/useEnableKeyframes.ts +116 -56
  57. package/src/hooks/useGestureCommit.ts +2 -2
  58. package/src/hooks/useGsapAnimationFetchFallback.ts +1 -1
  59. package/src/hooks/useGsapKeyframeOps.test.tsx +92 -13
  60. package/src/hooks/useGsapKeyframeOps.ts +30 -13
  61. package/src/hooks/useGsapScriptCommits.test.tsx +21 -0
  62. package/src/hooks/useGsapScriptCommits.ts +2 -0
  63. package/src/hooks/useGsapSelectionHandlers.test.tsx +69 -3
  64. package/src/hooks/useGsapSelectionHandlers.ts +90 -65
  65. package/src/hooks/useGsapTweenCache.test.ts +16 -0
  66. package/src/hooks/useGsapTweenCache.ts +64 -178
  67. package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +68 -0
  68. package/src/hooks/useStudioContextValue.test.ts +93 -0
  69. package/src/hooks/useStudioContextValue.ts +17 -3
  70. package/src/hooks/useStudioTestHooks.ts +63 -0
  71. package/src/hooks/useTimelineEditing.test.tsx +187 -22
  72. package/src/hooks/useTimelineEditing.ts +43 -32
  73. package/src/hooks/useTimelineEditingTypes.ts +2 -0
  74. package/src/hooks/useTimelineGroupEditing.ts +106 -82
  75. package/src/player/components/KeyframeDiamondContextMenu.test.tsx +69 -0
  76. package/src/player/components/KeyframeDiamondContextMenu.tsx +19 -8
  77. package/src/player/components/LayerDisclosureRow.tsx +76 -0
  78. package/src/player/components/Timeline.test.ts +420 -90
  79. package/src/player/components/Timeline.tsx +80 -78
  80. package/src/player/components/TimelineCanvas.tsx +33 -21
  81. package/src/player/components/TimelineClip.tsx +3 -1
  82. package/src/player/components/TimelineClipDiamonds.test.tsx +540 -16
  83. package/src/player/components/TimelineClipDiamonds.tsx +320 -117
  84. package/src/player/components/TimelineDiamondConnectors.tsx +145 -0
  85. package/src/player/components/TimelineLanes.tsx +153 -79
  86. package/src/player/components/TimelineOverlays.tsx +4 -18
  87. package/src/player/components/TimelinePropertyLanes.test.tsx +450 -0
  88. package/src/player/components/TimelinePropertyLanes.tsx +191 -0
  89. package/src/player/components/TimelineRazorInteraction.tsx +60 -0
  90. package/src/player/components/TimelineRuler.tsx +11 -21
  91. package/src/player/components/TimelineTrackHeader.test.tsx +346 -0
  92. package/src/player/components/TimelineTrackHeader.tsx +363 -0
  93. package/src/player/components/TrackClipCount.tsx +17 -0
  94. package/src/player/components/timelineCallbacks.ts +21 -6
  95. package/src/player/components/timelineClipDragPreview.test.ts +50 -3
  96. package/src/player/components/timelineClipDragPreview.ts +33 -15
  97. package/src/player/components/timelineCollision.ts +4 -13
  98. package/src/player/components/timelineDiamondTypes.ts +120 -0
  99. package/src/player/components/timelineDragDrop.ts +8 -3
  100. package/src/player/components/timelineEditing.test.ts +19 -27
  101. package/src/player/components/timelineEditing.ts +7 -8
  102. package/src/player/components/timelineKeyframeIdentity.test.ts +49 -0
  103. package/src/player/components/timelineKeyframeIdentity.ts +66 -0
  104. package/src/player/components/timelineLayout.test.ts +162 -9
  105. package/src/player/components/timelineLayout.ts +198 -38
  106. package/src/player/components/timelineMarquee.test.ts +76 -21
  107. package/src/player/components/timelineMarquee.ts +17 -14
  108. package/src/player/components/trackHeaderLaneState.ts +121 -0
  109. package/src/player/components/trackHeaderLaneValues.ts +139 -0
  110. package/src/player/components/useAutoExpandKeyframedClips.test.tsx +75 -0
  111. package/src/player/components/useAutoExpandKeyframedClips.ts +50 -0
  112. package/src/player/components/useTimelineClipDrag.ts +4 -1
  113. package/src/player/components/useTimelineGeometry.ts +4 -1
  114. package/src/player/components/useTimelineKeyframeHandlers.test.tsx +108 -0
  115. package/src/player/components/useTimelineKeyframeHandlers.ts +50 -15
  116. package/src/player/components/useTimelinePlayhead.ts +21 -12
  117. package/src/player/components/useTimelineRangeSelection.ts +35 -14
  118. package/src/player/components/useTimelineTrackLayout.test.ts +53 -0
  119. package/src/player/components/useTimelineTrackLayout.ts +162 -0
  120. package/src/player/hooks/useExpandedTimelineElements.test.ts +43 -0
  121. package/src/player/hooks/useExpandedTimelineElements.ts +21 -12
  122. package/src/player/lib/timelineElementHelpers.ts +26 -11
  123. package/src/player/store/keyframeSlice.ts +122 -0
  124. package/src/player/store/playerStore.test.ts +25 -0
  125. package/src/player/store/playerStore.ts +7 -43
  126. package/src/telemetry/events.test.ts +9 -3
  127. package/src/telemetry/events.ts +16 -7
  128. package/src/utils/globalTimeCompiler.test.ts +10 -0
  129. package/src/utils/globalTimeCompiler.ts +3 -3
  130. package/dist/assets/index-B37rWXo4.css +0 -1
  131. package/dist/assets/index-DzDajONI.js +0 -428
  132. package/src/player/components/TimelineDragGhost.tsx +0 -59
  133. package/src/utils/keyframeSelection.test.ts +0 -45
  134. package/src/utils/keyframeSelection.ts +0 -29
@@ -3,26 +3,25 @@ import type { ZoomMode } from "../store/playerStore";
3
3
 
4
4
  /* ── Layout constants ──────────────────────────────────────────────── */
5
5
  export const GUTTER = 32;
6
+ export const LABEL_COL_W = 232;
6
7
  export const TRACK_H = 48;
8
+ export const LANE_H = 28;
7
9
  export const RULER_H = 24;
8
10
  export const CLIP_Y = 3;
9
11
  export const CLIP_HANDLE_W = 18;
12
+
13
+ export function getTimelineLaneTop(laneIndex: number): number {
14
+ return TRACK_H + Math.max(0, Math.trunc(laneIndex)) * LANE_H;
15
+ }
10
16
  /**
11
- * Half-width (as a fraction of TRACK_H) of the new-track INSERT band that
12
- * straddles each lane boundary. Deliberately equals the clip's vertical inset
13
- * (`CLIP_Y / TRACK_H`): a clip body fills [CLIP_Y, TRACK_H − CLIP_Y] of its row,
14
- * so the ONLY region this band covers is the visible empty gutter between two
15
- * clip bodies (plus the top/bottom breathing pads, handled separately by the
16
- * rowFloat ≤ 0 / ≥ trackCount extremes). Aiming at a clip body is therefore a
17
- * move-to-that-lane; only the inter-clip gap arms an insert — see resolveInsertRow.
18
- * Threaded into resolveInsertRow by the drag preview so the hit band can never
19
- * drift from the rendered clip geometry.
17
+ * Collapsed-row characterization value for the new-track INSERT band. Runtime
18
+ * hit-testing uses getTimelineInsertBoundaryBand with the concrete row height.
20
19
  */
21
20
  export const INSERT_BOUNDARY_BAND = CLIP_Y / TRACK_H;
22
21
  /**
23
22
  * Breathing room INSIDE the scroll area (CapCut-style), threaded through every
24
23
  * track-row y computation via {@link getTimelineRowTop} — never inline a magic
25
- * offset; a track row's top is always `RULER_H + TRACKS_TOP_PAD + row*TRACK_H`.
24
+ * offset; a track row's top is always ruler + top pad + cumulative row heights.
26
25
  *
27
26
  * - TRACKS_TOP_PAD: empty space between the (sticky) ruler and the first track
28
27
  * (~half a track height) so the first clip isn't jammed under the ruler.
@@ -43,6 +42,79 @@ export const TRACKS_BOTTOM_PAD = Math.round(TRACK_H * 1.5);
43
42
  */
44
43
  export const TRACKS_LEFT_PAD = 48;
45
44
 
45
+ export interface TimelineTrackHeightClip {
46
+ clipId: string;
47
+ laneCount: number;
48
+ }
49
+
50
+ type TimelineTrackHeightInput = readonly (readonly TimelineTrackHeightClip[])[];
51
+
52
+ /**
53
+ * Resolve each track's full height. Without expansion state every row is the
54
+ * legacy TRACK_H; if multiple clips in one track expand, the tallest one owns
55
+ * the shared row height.
56
+ */
57
+ export function trackHeights(
58
+ tracks: TimelineTrackHeightInput,
59
+ expandedClipIds?: ReadonlySet<string>,
60
+ ): number[] {
61
+ return tracks.map((clips) => {
62
+ let laneCount = 0;
63
+ if (expandedClipIds) {
64
+ for (const clip of clips) {
65
+ if (expandedClipIds.has(clip.clipId)) laneCount = Math.max(laneCount, clip.laneCount);
66
+ }
67
+ }
68
+ return TRACK_H + Math.max(0, Math.trunc(laneCount)) * LANE_H;
69
+ });
70
+ }
71
+
72
+ function validRowHeight(height: number | undefined): number {
73
+ if (height === undefined || !Number.isFinite(height) || height <= 0) return TRACK_H;
74
+ return height;
75
+ }
76
+
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[]>();
85
+
86
+ /** Cumulative top offsets, including the final bottom boundary. */
87
+ 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;
96
+ }
97
+
98
+ export function getTimelineRowHeight(row: number, rowHeights: readonly number[] = []): number {
99
+ return validRowHeight(rowHeights[row]);
100
+ }
101
+
102
+ 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);
116
+ }
117
+
46
118
  /**
47
119
  * The y (content-space) of the top edge of track ROW index `row` (0 = first
48
120
  * displayed lane). The single source of truth for row→y — the ruler height plus
@@ -50,17 +122,48 @@ export const TRACKS_LEFT_PAD = 48;
50
122
  * placeholder/insertion top and every pointer-y→row inversion goes through this
51
123
  * (or its inverse in {@link getTimelineRowFromY}) so the pad can never drift.
52
124
  */
53
- export function getTimelineRowTop(row: number): number {
54
- return RULER_H + TRACKS_TOP_PAD + row * TRACK_H;
125
+ export function getTimelineRowTop(row: number, rowHeights: readonly number[] = []): number {
126
+ return RULER_H + TRACKS_TOP_PAD + getTimelineRowOffset(row, rowHeights);
55
127
  }
56
128
 
57
129
  /**
58
130
  * Inverse of {@link getTimelineRowTop}: the fractional row index for a content-
59
- * space y (used for insert-row / drop-lane decisions). Subtracts the ruler and
60
- * top pad before dividing by the track height.
131
+ * space y (used for insert-row / drop-lane decisions). Locates the concrete row
132
+ * from cumulative offsets, then returns its local fractional position.
61
133
  */
62
- export function getTimelineRowFromY(contentY: number): number {
63
- return (contentY - RULER_H - TRACKS_TOP_PAD) / TRACK_H;
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;
148
+ }
149
+
150
+ export function getTimelineRowPositionFromY(
151
+ contentY: number,
152
+ rowHeights: readonly number[] = [],
153
+ ): { 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
+ };
162
+ }
163
+
164
+ /** Fractional insert band for the concrete row under a pointer. */
165
+ export function getTimelineInsertBoundaryBand(rowHeight: number): number {
166
+ return CLIP_Y / validRowHeight(rowHeight);
64
167
  }
65
168
  /**
66
169
  * While a clip drag is live, the rendered timeline extends this far past the
@@ -216,12 +319,16 @@ export function formatTimelineTickLabel(time: number, duration: number, majorInt
216
319
  * remaining ruler runs to 1:00.
217
320
  * Manual zoom multiplies this base, so the floor only anchors the default.
218
321
  */
219
- export function getTimelineFitPps(viewportWidth: number, effectiveDuration: number): number {
322
+ export function getTimelineFitPps(
323
+ viewportWidth: number,
324
+ effectiveDuration: number,
325
+ contentOrigin: number,
326
+ ): number {
220
327
  const safeDuration =
221
328
  Number.isFinite(effectiveDuration) && effectiveDuration > 0 ? effectiveDuration : 0;
222
329
  const span = Math.max(safeDuration * FIT_ZOOM_HEADROOM, MIN_TIMELINE_EXTENT_S);
223
- if (!Number.isFinite(viewportWidth) || viewportWidth <= GUTTER + TRACKS_LEFT_PAD) return 100;
224
- return (viewportWidth - GUTTER - TRACKS_LEFT_PAD - 2) / span;
330
+ if (!Number.isFinite(viewportWidth) || viewportWidth <= contentOrigin) return 100;
331
+ return (viewportWidth - contentOrigin - 2) / span;
225
332
  }
226
333
 
227
334
  /**
@@ -234,6 +341,7 @@ export function getTimelineFitPps(viewportWidth: number, effectiveDuration: numb
234
341
  export function getTimelineDisplayContentWidth(input: {
235
342
  trackContentWidth: number;
236
343
  viewportWidth: number;
344
+ contentOrigin: number;
237
345
  pps: number;
238
346
  dragGhostEndPx?: number;
239
347
  resizeGhostEndPx?: number;
@@ -241,7 +349,7 @@ export function getTimelineDisplayContentWidth(input: {
241
349
  const safePps = Number.isFinite(input.pps) ? Math.max(input.pps, 0) : 0;
242
350
  return Math.max(
243
351
  input.trackContentWidth,
244
- input.viewportWidth - GUTTER - TRACKS_LEFT_PAD - 2,
352
+ input.viewportWidth - input.contentOrigin - 2,
245
353
  input.dragGhostEndPx ?? 0,
246
354
  input.resizeGhostEndPx ?? 0,
247
355
  MIN_TIMELINE_EXTENT_S * safePps,
@@ -249,6 +357,15 @@ export function getTimelineDisplayContentWidth(input: {
249
357
  }
250
358
 
251
359
  /* ── Scroll / zoom helpers ────────────────────────────────────────── */
360
+ export function getTimelineContentXFromClient(input: {
361
+ clientX: number;
362
+ rectLeft: number;
363
+ scrollLeft: number;
364
+ contentOrigin: number;
365
+ }): number {
366
+ return input.clientX - input.rectLeft + input.scrollLeft - input.contentOrigin;
367
+ }
368
+
252
369
  export function shouldAutoScrollTimeline(
253
370
  zoomMode: ZoomMode,
254
371
  scrollWidth: number,
@@ -271,7 +388,7 @@ export function getTimelineScrollLeftForZoomTransition(
271
388
  export function getTimelineScrollLeftForZoomAnchor(input: {
272
389
  pointerX: number;
273
390
  currentScrollLeft: number;
274
- gutter: number;
391
+ contentOrigin: number;
275
392
  currentPixelsPerSecond: number;
276
393
  nextPixelsPerSecond: number;
277
394
  duration: number;
@@ -288,9 +405,17 @@ export function getTimelineScrollLeftForZoomAnchor(input: {
288
405
  ) {
289
406
  return Math.max(0, input.currentScrollLeft);
290
407
  }
291
- const timelineX = Math.max(0, input.currentScrollLeft + input.pointerX - input.gutter);
408
+ const timelineX = Math.max(
409
+ 0,
410
+ getTimelineContentXFromClient({
411
+ clientX: input.pointerX,
412
+ rectLeft: 0,
413
+ scrollLeft: input.currentScrollLeft,
414
+ contentOrigin: input.contentOrigin,
415
+ }),
416
+ );
292
417
  const timeAtPointer = Math.max(0, Math.min(input.duration, timelineX / currentPps));
293
- return Math.max(0, input.gutter + timeAtPointer * nextPps - input.pointerX);
418
+ return Math.max(0, input.contentOrigin + timeAtPointer * nextPps - input.pointerX);
294
419
  }
295
420
 
296
421
  /* ── Playhead / canvas ────────────────────────────────────────────── */
@@ -299,33 +424,62 @@ export function getTimelineScrollLeftForZoomAnchor(input: {
299
424
  * width, which the wrapper shrink-wraps to). The 1px vertical line inside
300
425
  * PlayheadIndicator is centered at 50% of this wrapper, so the wrapper must be
301
426
  * shifted LEFT by half this width for the line's center to land exactly on
302
- * `GUTTER + time * pps` — see {@link getTimelinePlayheadLeft}.
427
+ * `contentOrigin + time * pps` — see {@link getTimelinePlayheadLeft}.
303
428
  */
304
429
  export const PLAYHEAD_HEAD_W = 9;
305
430
 
306
431
  /**
307
432
  * The `left` for the playhead WRAPPER such that the vertical line's CENTER
308
- * sits exactly on `GUTTER + time * pps` (the same x the ruler ticks center
433
+ * sits exactly on `contentOrigin + time * pps` (the same x the ruler ticks center
309
434
  * on) at every zoom level. Without the half-head offset the line sat
310
435
  * `PLAYHEAD_HEAD_W / 2` px to the right of its ruler tick.
311
436
  */
312
- export function getTimelinePlayheadLeft(time: number, pixelsPerSecond: number): number {
437
+ export function getTimelinePlayheadLeft(
438
+ time: number,
439
+ pixelsPerSecond: number,
440
+ contentOrigin: number,
441
+ ): number {
313
442
  if (!Number.isFinite(time) || !Number.isFinite(pixelsPerSecond)) {
314
- return GUTTER + TRACKS_LEFT_PAD - PLAYHEAD_HEAD_W / 2;
443
+ return contentOrigin - PLAYHEAD_HEAD_W / 2;
315
444
  }
316
- return (
317
- GUTTER +
318
- TRACKS_LEFT_PAD +
319
- Math.max(0, time) * Math.max(0, pixelsPerSecond) -
320
- PLAYHEAD_HEAD_W / 2
321
- );
445
+ return contentOrigin + Math.max(0, time) * Math.max(0, pixelsPerSecond) - PLAYHEAD_HEAD_W / 2;
446
+ }
447
+
448
+ /**
449
+ * Inverse of {@link getTimelinePlayheadLeft}: the scrub time under a viewport
450
+ * clientX. Clamped to [0, duration], NOT rejected — the scrub surface starts
451
+ * `contentOrigin` px right of the viewport edge, so any pointer left of t=0
452
+ * maps to a negative offset. Callers used to bail on that instead of
453
+ * clamping, which made the last 80px of the drag to zero silently do nothing:
454
+ * the playhead stuck wherever the last in-range sample landed, and only a very
455
+ * slow drag that happened to sample inside the sliver before the origin reached
456
+ * 0. Every scrub path shares this so they cannot diverge on the edge again.
457
+ */
458
+ export function getTimelineScrubTime(input: {
459
+ clientX: number;
460
+ viewportLeft: number;
461
+ scrollLeft: number;
462
+ contentOrigin: number;
463
+ pixelsPerSecond: number;
464
+ duration: number;
465
+ }): number {
466
+ const { clientX, viewportLeft, scrollLeft, contentOrigin, pixelsPerSecond, duration } = input;
467
+ if (!(pixelsPerSecond > 0) || !Number.isFinite(duration)) return 0;
468
+ const x = getTimelineContentXFromClient({
469
+ clientX,
470
+ rectLeft: viewportLeft,
471
+ scrollLeft,
472
+ contentOrigin,
473
+ });
474
+ return Math.max(0, Math.min(duration, x / pixelsPerSecond));
322
475
  }
323
476
 
324
- export function getTimelineCanvasHeight(trackCount: number): number {
477
+ export function getTimelineCanvasHeight(rowHeights: readonly number[]): number {
325
478
  // RULER_H + top pad + lanes + bottom pad. The old TIMELINE_SCROLL_BUFFER is
326
479
  // subsumed by TRACKS_BOTTOM_PAD (which is larger), so the drag-into-void space
327
480
  // below the last lane is real scrollable surface, not a hidden buffer.
328
- return RULER_H + TRACKS_TOP_PAD + Math.max(0, trackCount) * TRACK_H + TRACKS_BOTTOM_PAD;
481
+ const rowsHeight = getTimelineRowOffsets(rowHeights).at(-1) ?? 0;
482
+ return RULER_H + TRACKS_TOP_PAD + rowsHeight + TRACKS_BOTTOM_PAD;
329
483
  }
330
484
 
331
485
  /* ── UI helpers ───────────────────────────────────────────────────── */
@@ -381,16 +535,22 @@ export function resolveTimelineAssetDrop(
381
535
  rectTop: number;
382
536
  scrollLeft: number;
383
537
  scrollTop: number;
538
+ contentOrigin: number;
384
539
  pixelsPerSecond: number;
385
540
  duration: number;
386
541
  clampStartToDuration?: boolean;
387
- trackHeight: number;
542
+ rowHeights?: readonly number[];
388
543
  trackOrder: number[];
389
544
  },
390
545
  clientX: number,
391
546
  clientY: number,
392
547
  ): { start: number; track: number } {
393
- const x = clientX - input.rectLeft + input.scrollLeft - GUTTER - TRACKS_LEFT_PAD;
548
+ const x = getTimelineContentXFromClient({
549
+ clientX,
550
+ rectLeft: input.rectLeft,
551
+ scrollLeft: input.scrollLeft,
552
+ contentOrigin: input.contentOrigin,
553
+ });
394
554
  const contentY = clientY - input.rectTop + input.scrollTop;
395
555
  const pointerStart = Math.round((x / Math.max(input.pixelsPerSecond, 1)) * 100) / 100;
396
556
  const start = Math.max(
@@ -400,7 +560,7 @@ export function resolveTimelineAssetDrop(
400
560
  // Row from the shared row→y inverse so the top pad is honoured; a drop in the
401
561
  // pad above the first lane floors to row 0, a drop in the bottom pad rounds
402
562
  // past the last lane (getDefaultDroppedTrack then appends a new track).
403
- const rowIndex = Math.floor(getTimelineRowFromY(contentY));
563
+ const rowIndex = Math.floor(getTimelineRowFromY(contentY, input.rowHeights));
404
564
  return {
405
565
  start,
406
566
  track: getDefaultDroppedTrack(input.trackOrder, rowIndex),
@@ -9,6 +9,7 @@ import {
9
9
  } from "./timelineMarquee";
10
10
  import {
11
11
  GUTTER,
12
+ LANE_H,
12
13
  TRACK_H,
13
14
  RULER_H,
14
15
  CLIP_Y,
@@ -16,7 +17,9 @@ import {
16
17
  getTimelineRowTop,
17
18
  } from "./timelineLayout";
18
19
 
19
- // Canvas-space time origin: right edge of the sticky gutter + the left pad.
20
+ // Canvas-space time origin used by the breathing-pad (default) test cases: right
21
+ // edge of the sticky gutter + the left pad. Other cases pass GUTTER or LABEL_COL_W
22
+ // directly as contentOrigin to test the plain/keyframe-label-column origins.
20
23
  const ORIGIN = GUTTER + TRACKS_LEFT_PAD;
21
24
 
22
25
  describe("isTimelineRulerPress", () => {
@@ -94,9 +97,9 @@ describe("getTimelineClipRect", () => {
94
97
  const trackOrder = [0, 2, 5];
95
98
 
96
99
  it("maps start/duration to x via pps and the track row to y via the shared row→y helper", () => {
97
- const rect = getTimelineClipRect({ start: 2, duration: 3, track: 2 }, trackOrder, 100);
100
+ const rect = getTimelineClipRect({ start: 2, duration: 3, track: 2 }, trackOrder, 100, GUTTER);
98
101
  expect(rect).toEqual({
99
- left: ORIGIN + 200,
102
+ left: GUTTER + 200,
100
103
  top: getTimelineRowTop(1) + CLIP_Y,
101
104
  width: 300,
102
105
  height: TRACK_H - CLIP_Y * 2,
@@ -104,25 +107,55 @@ describe("getTimelineClipRect", () => {
104
107
  });
105
108
 
106
109
  it("places the first visible track below the ruler + top breathing pad", () => {
107
- const rect = getTimelineClipRect({ start: 0, duration: 1, track: 0 }, trackOrder, 50);
110
+ const rect = getTimelineClipRect({ start: 0, duration: 1, track: 0 }, trackOrder, 50, GUTTER);
108
111
  expect(rect?.top).toBe(getTimelineRowTop(0) + CLIP_Y);
109
- expect(rect?.left).toBe(ORIGIN);
112
+ expect(rect?.left).toBe(GUTTER);
110
113
  });
111
114
 
112
115
  it("uses the row index in trackOrder, not the raw track number", () => {
113
- const rect = getTimelineClipRect({ start: 0, duration: 1, track: 5 }, trackOrder, 50);
116
+ const rect = getTimelineClipRect({ start: 0, duration: 1, track: 5 }, trackOrder, 50, GUTTER);
114
117
  expect(rect?.top).toBe(getTimelineRowTop(2) + CLIP_Y);
115
118
  });
116
119
 
120
+ it("uses cumulative tops and the resolved height for an expanded row", () => {
121
+ const rowHeights = [TRACK_H + 2 * LANE_H, TRACK_H, TRACK_H];
122
+ const rect = getTimelineClipRect(
123
+ { start: 0, duration: 1, track: 0 },
124
+ trackOrder,
125
+ 50,
126
+ GUTTER,
127
+ rowHeights,
128
+ );
129
+ expect(rect).toMatchObject({
130
+ top: getTimelineRowTop(0, rowHeights) + CLIP_Y,
131
+ height: rowHeights[0] - CLIP_Y * 2,
132
+ });
133
+ expect(
134
+ getTimelineClipRect({ start: 0, duration: 1, track: 2 }, trackOrder, 50, GUTTER, rowHeights)
135
+ ?.top,
136
+ ).toBe(getTimelineRowTop(1, rowHeights) + CLIP_Y);
137
+ });
138
+
117
139
  it("enforces the 4px minimum rendered width", () => {
118
- const rect = getTimelineClipRect({ start: 0, duration: 0.01, track: 0 }, trackOrder, 10);
140
+ const rect = getTimelineClipRect(
141
+ { start: 0, duration: 0.01, track: 0 },
142
+ trackOrder,
143
+ 10,
144
+ GUTTER,
145
+ );
119
146
  expect(rect?.width).toBe(4);
120
147
  });
121
148
 
122
149
  it("returns null for a track that is not displayed or an invalid pps", () => {
123
- expect(getTimelineClipRect({ start: 0, duration: 1, track: 9 }, trackOrder, 100)).toBeNull();
124
- expect(getTimelineClipRect({ start: 0, duration: 1, track: 0 }, trackOrder, 0)).toBeNull();
125
- expect(getTimelineClipRect({ start: 0, duration: 1, track: 0 }, trackOrder, NaN)).toBeNull();
150
+ expect(
151
+ getTimelineClipRect({ start: 0, duration: 1, track: 9 }, trackOrder, 100, GUTTER),
152
+ ).toBeNull();
153
+ expect(
154
+ getTimelineClipRect({ start: 0, duration: 1, track: 0 }, trackOrder, 0, GUTTER),
155
+ ).toBeNull();
156
+ expect(
157
+ getTimelineClipRect({ start: 0, duration: 1, track: 0 }, trackOrder, NaN, GUTTER),
158
+ ).toBeNull();
126
159
  });
127
160
  });
128
161
 
@@ -140,29 +173,48 @@ describe("computeMarqueeSelection", () => {
140
173
 
141
174
  it("selects only the clips the marquee rect intersects", () => {
142
175
  const marquee = { left: ORIGIN, top: row0Top, width: 50, height: 10 };
143
- const { ids, primaryId } = computeMarqueeSelection({ clips, trackOrder, pps, marquee });
176
+ const { ids, primaryId } = computeMarqueeSelection({
177
+ clips,
178
+ trackOrder,
179
+ pps,
180
+ contentOrigin: ORIGIN,
181
+ marquee,
182
+ });
144
183
  expect(ids).toEqual(new Set(["a"]));
145
184
  expect(primaryId).toBe("a");
146
185
  });
147
186
 
148
187
  it("selects across tracks when the rect spans multiple rows", () => {
149
188
  const marquee = { left: ORIGIN, top: row0Top, width: 60, height: row1Top - row0Top + 5 };
150
- const { ids } = computeMarqueeSelection({ clips, trackOrder, pps, marquee });
189
+ const { ids } = computeMarqueeSelection({
190
+ clips,
191
+ trackOrder,
192
+ pps,
193
+ contentOrigin: ORIGIN,
194
+ marquee,
195
+ });
151
196
  expect(ids).toEqual(new Set(["a", "c"]));
152
197
  });
153
198
 
154
199
  it("excludes clips outside the rect horizontally", () => {
155
200
  const marquee = { left: ORIGIN + 140, top: row0Top, width: 50, height: 10 };
156
- const { ids } = computeMarqueeSelection({ clips, trackOrder, pps, marquee });
201
+ const { ids } = computeMarqueeSelection({
202
+ clips,
203
+ trackOrder,
204
+ pps,
205
+ contentOrigin: ORIGIN,
206
+ marquee,
207
+ });
157
208
  expect(ids).toEqual(new Set());
158
209
  });
159
210
 
160
211
  it("returns null primaryId and keeps the base when nothing is hit (additive)", () => {
161
- const marquee = { left: ORIGIN + 140, top: row0Top, width: 50, height: 10 };
212
+ const marquee = { left: GUTTER + 140, top: row0Top, width: 50, height: 10 };
162
213
  const { ids, primaryId } = computeMarqueeSelection({
163
214
  clips,
164
215
  trackOrder,
165
216
  pps,
217
+ contentOrigin: GUTTER,
166
218
  marquee,
167
219
  baseSelection: ["b"],
168
220
  });
@@ -171,11 +223,12 @@ describe("computeMarqueeSelection", () => {
171
223
  });
172
224
 
173
225
  it("unions additive base selection with new hits; primary comes from the marquee", () => {
174
- const marquee = { left: ORIGIN, top: row1Top, width: 100, height: 10 };
226
+ const marquee = { left: GUTTER, top: row1Top, width: 100, height: 10 };
175
227
  const { ids, primaryId } = computeMarqueeSelection({
176
228
  clips,
177
229
  trackOrder,
178
230
  pps,
231
+ contentOrigin: GUTTER,
179
232
  marquee,
180
233
  baseSelection: ["b"],
181
234
  });
@@ -186,12 +239,13 @@ describe("computeMarqueeSelection", () => {
186
239
  it("shrinking the rect live drops clips it no longer covers", () => {
187
240
  const wide = { left: ORIGIN, top: row0Top, width: 320, height: 10 };
188
241
  const narrow = { left: ORIGIN, top: row0Top, width: 80, height: 10 };
189
- expect(computeMarqueeSelection({ clips, trackOrder, pps, marquee: wide }).ids).toEqual(
190
- new Set(["a", "b"]),
191
- );
192
- expect(computeMarqueeSelection({ clips, trackOrder, pps, marquee: narrow }).ids).toEqual(
193
- new Set(["a"]),
194
- );
242
+ expect(
243
+ computeMarqueeSelection({ clips, trackOrder, pps, contentOrigin: ORIGIN, marquee: wide }).ids,
244
+ ).toEqual(new Set(["a", "b"]));
245
+ expect(
246
+ computeMarqueeSelection({ clips, trackOrder, pps, contentOrigin: ORIGIN, marquee: narrow })
247
+ .ids,
248
+ ).toEqual(new Set(["a"]));
195
249
  });
196
250
 
197
251
  it("ignores clips on hidden/undisplayed tracks", () => {
@@ -200,6 +254,7 @@ describe("computeMarqueeSelection", () => {
200
254
  clips: [{ id: "x", start: 0, duration: 1, track: 7 }],
201
255
  trackOrder,
202
256
  pps,
257
+ contentOrigin: GUTTER,
203
258
  marquee,
204
259
  });
205
260
  expect(ids).toEqual(new Set());
@@ -1,11 +1,4 @@
1
- import {
2
- GUTTER,
3
- TRACK_H,
4
- RULER_H,
5
- CLIP_Y,
6
- TRACKS_LEFT_PAD,
7
- getTimelineRowTop,
8
- } from "./timelineLayout";
1
+ import { RULER_H, CLIP_Y, getTimelineRowHeight, getTimelineRowTop } from "./timelineLayout";
9
2
  import { rectsOverlap, type Rect } from "../../utils/marqueeGeometry";
10
3
 
11
4
  /** Pointer must travel at least this far (either axis) before a pointerdown on
@@ -68,22 +61,24 @@ export function getMarqueeRect(
68
61
 
69
62
  /**
70
63
  * A clip's rendered rect in canvas/content coordinates (the same space the
71
- * marquee rect lives in): x from GUTTER + start * pps, y from the clip's row
72
- * index within the visible track order (RULER_H + row * TRACK_H + CLIP_Y).
64
+ * marquee rect lives in): x from the shared content origin + start * pps, y from the clip's row
65
+ * index within the visible track order (cumulative row top + CLIP_Y).
73
66
  * Returns null when the clip's track is not currently displayed.
74
67
  */
75
68
  export function getTimelineClipRect(
76
69
  clip: Pick<MarqueeClipInput, "start" | "duration" | "track">,
77
70
  trackOrder: number[],
78
71
  pps: number,
72
+ contentOrigin: number,
73
+ rowHeights: readonly number[] = [],
79
74
  ): Rect | null {
80
75
  const row = trackOrder.indexOf(clip.track);
81
76
  if (row < 0 || !Number.isFinite(pps) || pps <= 0) return null;
82
77
  return {
83
- left: GUTTER + TRACKS_LEFT_PAD + clip.start * pps,
84
- top: getTimelineRowTop(row) + CLIP_Y,
78
+ left: contentOrigin + clip.start * pps,
79
+ top: getTimelineRowTop(row, rowHeights) + CLIP_Y,
85
80
  width: Math.max(clip.duration * pps, MIN_CLIP_W),
86
- height: TRACK_H - CLIP_Y * 2,
81
+ height: getTimelineRowHeight(row, rowHeights) - CLIP_Y * 2,
87
82
  };
88
83
  }
89
84
 
@@ -103,13 +98,21 @@ export function computeMarqueeSelection(input: {
103
98
  clips: MarqueeClipInput[];
104
99
  trackOrder: number[];
105
100
  pps: number;
101
+ contentOrigin: number;
106
102
  marquee: Rect;
107
103
  baseSelection?: Iterable<string>;
104
+ rowHeights?: readonly number[];
108
105
  }): MarqueeSelectionResult {
109
106
  const ids = new Set<string>(input.baseSelection ?? []);
110
107
  let primaryId: string | null = null;
111
108
  for (const clip of input.clips) {
112
- const rect = getTimelineClipRect(clip, input.trackOrder, input.pps);
109
+ const rect = getTimelineClipRect(
110
+ clip,
111
+ input.trackOrder,
112
+ input.pps,
113
+ input.contentOrigin,
114
+ input.rowHeights,
115
+ );
113
116
  if (rect && rectsOverlap(rect, input.marquee)) {
114
117
  ids.add(clip.id);
115
118
  primaryId = clip.id;