@hyperframes/studio 0.7.85 → 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 (95) hide show
  1. package/dist/assets/{hyperframes-player-CXVkvR6C.js → hyperframes-player-BFqB5AWJ.js} +1 -1
  2. package/dist/assets/{index-CVcz5MoC.js → index-B1Vf4vCl.js} +1 -1
  3. package/dist/assets/index-BXkzp_OU.css +1 -0
  4. package/dist/assets/{index-DloBOPGY.js → index-BblzZ6Av.js} +221 -221
  5. package/dist/assets/{index-BZFIup06.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 +6266 -4923
  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/useTimelinePerformanceTelemetry.test.ts +68 -2
  61. package/src/player/components/useTimelinePerformanceTelemetry.ts +1 -1
  62. package/src/player/components/useTimelinePlayhead.ts +30 -14
  63. package/src/player/components/useTimelineRangeSelection.ts +5 -4
  64. package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +3 -2
  65. package/src/player/components/useTimelineRevealClip.test.tsx +139 -0
  66. package/src/player/components/useTimelineRevealClip.ts +171 -47
  67. package/src/player/components/useTimelineRowVirtualization.ts +140 -0
  68. package/src/player/components/useTimelineScrollViewport.test.tsx +202 -0
  69. package/src/player/components/useTimelineScrollViewport.ts +87 -9
  70. package/src/player/components/useTimelineSelectionLifecycle.ts +29 -0
  71. package/src/player/components/useTimelineShiftModifier.ts +20 -0
  72. package/src/player/components/useTimelineTicks.ts +22 -0
  73. package/src/player/components/useTimelineTrackLayout.test.ts +32 -26
  74. package/src/player/components/useTimelineTrackLayout.ts +35 -18
  75. package/src/player/components/useTimelineVirtualRows.test.tsx +174 -0
  76. package/src/player/components/useTimelineVirtualRows.ts +126 -0
  77. package/src/player/hooks/timelinePlayerSync.ts +20 -0
  78. package/src/player/hooks/useTimelinePlayer.seek.test.ts +90 -51
  79. package/src/player/hooks/useTimelinePlayer.ts +8 -20
  80. package/src/player/lib/playbackScrub.ts +2 -2
  81. package/src/player/lib/timelineClipIndex.test.ts +169 -0
  82. package/src/player/lib/timelineClipIndex.ts +208 -0
  83. package/src/player/lib/timelineElementIndexes.test.ts +31 -0
  84. package/src/player/lib/timelineElementIndexes.ts +44 -0
  85. package/src/player/lib/timelinePerformanceDiagnostics.test.ts +100 -0
  86. package/src/player/lib/timelinePerformanceDiagnostics.ts +120 -0
  87. package/src/player/lib/timelinePerformanceFixture.ts +167 -0
  88. package/src/player/lib/timelineViewportBudgets.test.ts +60 -0
  89. package/src/player/lib/timelineViewportBudgets.ts +151 -0
  90. package/src/player/store/playerStore.test.ts +20 -3
  91. package/src/player/store/playerStore.ts +73 -45
  92. package/src/utils/studioHelpers.test.ts +48 -1
  93. package/src/utils/studioHelpers.ts +31 -34
  94. package/src/utils/studioUiPreferences.ts +3 -1
  95. package/dist/assets/index-D379YOKT.css +0 -1
@@ -8,6 +8,7 @@ import {
8
8
  import { TimelineDiamondConnectors } from "./TimelineDiamondConnectors";
9
9
  import { clipToTweenPercentage } from "../../components/editor/KeyframeNavigation";
10
10
  import { LANE_H } from "./timelineLayout";
11
+ import { STUDIO_PREVIEW_FPS } from "../lib/time";
11
12
  import { timelineKeyframeSelectionKey } from "./timelineKeyframeIdentity";
12
13
  import {
13
14
  DIAMOND_RATIO,
@@ -22,11 +23,10 @@ import {
22
23
 
23
24
  export type { TimelineDiamondKeyframe } from "./timelineDiamondTypes";
24
25
 
25
- // Floor for a diamond's clickable width. The visual size still narrows to the
26
- // neighbour gap so packed diamonds stay individually readable, but the hit box
27
- // stops there: at the zoom floor the gap alone left a ~7px target, which is
28
- // neither hittable nor selectable with any accuracy. Boxes may overlap slightly
29
- // below this width; each diamond still owns the half-gap around its own centre.
26
+ // Floor for a diamond's clickable width. The visible diamond stays full-size
27
+ // even when neighbours overlap; shrinking recorded-gesture keyframes into dots
28
+ // makes the timeline unreadable. The hit box still follows the neighbour gap
29
+ // and stops at this floor so nearby timestamps remain separately targetable.
30
30
  //
31
31
  // Deliberately below the 24px WCAG 2.2 (2.5.8) minimum, under that criterion's
32
32
  // own spacing exception: at normal keyframe density the neighbour gap is under
@@ -40,10 +40,36 @@ function roundPct(percentage: number): number {
40
40
  return Math.round(percentage * 1000) / 1000;
41
41
  }
42
42
 
43
+ /** Find the closest authored keyframe without scanning the full lane on every playhead tick. */
44
+ function nearestKeyframeWithin(
45
+ sorted: TimelineDiamondKeyframe[],
46
+ percentage: number,
47
+ tolerance: number,
48
+ ): TimelineDiamondKeyframe | null {
49
+ if (!Number.isFinite(percentage) || tolerance <= 0 || sorted.length === 0) return null;
50
+ let low = 0;
51
+ let high = sorted.length;
52
+ while (low < high) {
53
+ const mid = (low + high) >>> 1;
54
+ if (sorted[mid]!.percentage < percentage) low = mid + 1;
55
+ else high = mid;
56
+ }
57
+ const before = sorted[low - 1];
58
+ const after = sorted[low];
59
+ const nearest =
60
+ before && after
61
+ ? percentage - before.percentage <= after.percentage - percentage
62
+ ? before
63
+ : after
64
+ : (before ?? after);
65
+ return nearest && Math.abs(nearest.percentage - percentage) <= tolerance ? nearest : null;
66
+ }
67
+
43
68
  export const TimelineDiamondLane = memo(function TimelineDiamondLane({
44
69
  keyframesData,
45
70
  clipWidthPx,
46
71
  clipHeightPx,
72
+ clipDuration,
47
73
  beatsActive,
48
74
  accentColor,
49
75
  isSelected,
@@ -197,14 +223,24 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
197
223
  const previousGap = previous ? centerX - centerXOf(previous.percentage) : Infinity;
198
224
  const nextGap = next ? centerXOf(next.percentage) - centerX : Infinity;
199
225
  const nearestGap = Math.max(1, Math.min(previousGap, nextGap));
200
- const gapWidth = Math.min(diamondSize, nearestGap);
226
+ const hitWidth = Math.min(diamondSize, nearestGap);
201
227
  return {
202
228
  keyframe,
203
229
  centerX,
204
- hitWidth: Math.max(KF_MIN_HIT_W, gapWidth),
205
- visualSize: gapWidth === diamondSize ? diamondSize : Math.max(2, gapWidth - 2),
230
+ hitWidth: Math.max(KF_MIN_HIT_W, hitWidth),
231
+ visualSize: diamondSize,
206
232
  };
207
233
  });
234
+ // The playhead is a cursor, not a selection. Resolve one nearest keyframe per
235
+ // rendered property lane and only while it is within half an output frame.
236
+ // A fixed clip-% tolerance grows with duration and lit whole clusters of
237
+ // gesture-recorded keyframes at once on long clips.
238
+ const halfFramePct =
239
+ clipDuration && clipDuration > 0 ? 50 / STUDIO_PREVIEW_FPS / clipDuration : 0;
240
+ const playheadKeyframe =
241
+ isSelected && halfFramePct > 0
242
+ ? nearestKeyframeWithin(sorted, currentPercentage, halfFramePct)
243
+ : null;
208
244
  const baseColor = isSelected ? accentColor : "#a3a3a3";
209
245
  const baseOpacity = isSelected ? 0.4 : 0.25;
210
246
  const canDrag = isSelected && !!onMoveKeyframe;
@@ -251,9 +287,9 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
251
287
  // fully visible instead of being clipped by the sticky label column.
252
288
  const leftPx = (renderPct / 100) * clipWidthPx - marker.hitWidth / 2;
253
289
  const isKfSelected = selectedKeyframes.has(kfKey);
254
- const atPlayhead = isSelected && Math.abs(kf.percentage - currentPercentage) < 0.5;
290
+ const atPlayhead = kf === playheadKeyframe;
255
291
  const isHighlighted = isKfSelected || atPlayhead;
256
- const color = isHighlighted ? accentColor : "#a3a3a3";
292
+ const color = isKfSelected ? accentColor : "#a3a3a3";
257
293
 
258
294
  const onPointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {
259
295
  if (e.button !== 0) return;
@@ -417,6 +453,10 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
417
453
  data-keyframe-percentage={
418
454
  groupAware ? (kf.tweenPercentage ?? kf.percentage) : undefined
419
455
  }
456
+ data-keyframe-at-playhead={String(atPlayhead)}
457
+ data-keyframe-selected={String(isKfSelected)}
458
+ aria-current={atPlayhead ? "time" : undefined}
459
+ aria-pressed={isKfSelected}
420
460
  style={{
421
461
  left: leftPx,
422
462
  top: centerY,
@@ -461,19 +501,19 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
461
501
  viewBox="0 0 10 10"
462
502
  style={{ flexShrink: 0, pointerEvents: "none" }}
463
503
  >
464
- {isKfSelected && (
504
+ {(isKfSelected || atPlayhead) && (
465
505
  <path
466
506
  d="M5 0L10 5L5 10L0 5Z"
467
507
  fill="none"
468
508
  stroke={accentColor}
469
509
  strokeWidth="0.8"
470
- opacity={0.5}
510
+ opacity={isKfSelected ? 0.5 : 1}
471
511
  />
472
512
  )}
473
513
  <path
474
514
  d="M5 1L9 5L5 9L1 5Z"
475
515
  fill={color}
476
- opacity={isKfSelected || atPlayhead ? 1 : 0.55}
516
+ opacity={isKfSelected ? 1 : atPlayhead ? 0.9 : 0.55}
477
517
  />
478
518
  </svg>
479
519
  </button>
@@ -7,7 +7,8 @@ import { afterEach, describe, expect, it, vi } from "vitest";
7
7
  import { TimelineLanes } from "./TimelineLanes";
8
8
  import { getTrackStyle } from "./timelineIcons";
9
9
  import { defaultTimelineTheme } from "./timelineTheme";
10
- import { TRACK_H } from "./timelineLayout";
10
+ import { TRACK_H, getTimelineRowGeometry } from "./timelineLayout";
11
+ import { createTimelineClipIndex } from "../lib/timelineClipIndex";
11
12
  import { usePlayerStore, type TimelineElement } from "../store/playerStore";
12
13
  import type { MultiDragPreviewInput } from "./timelineMultiDragPreview";
13
14
  import type { TimelineEditCallbacks } from "./timelineCallbacks";
@@ -78,6 +79,7 @@ function renderLanes(options: RenderLanesOptions = {}): {
78
79
  const laneCounts = new Map(
79
80
  elements.map((el) => [el.id, (gsapAnimations.get(el.id) ?? []).length]),
80
81
  );
82
+ const rowHeights = displayTrackOrder.map(() => TRACK_H);
81
83
  act(() => {
82
84
  usePlayerStore.setState({ expandedClipIds: new Set(next.expandedClipIds ?? []) });
83
85
  root.render(
@@ -88,7 +90,13 @@ function renderLanes(options: RenderLanesOptions = {}): {
88
90
  trackContentWidth={800}
89
91
  theme={defaultTimelineTheme}
90
92
  displayTrackOrder={displayTrackOrder}
91
- rowHeights={displayTrackOrder.map(() => TRACK_H)}
93
+ rowHeights={rowHeights}
94
+ rowGeometry={getTimelineRowGeometry(rowHeights)}
95
+ virtualRows={displayTrackOrder.map((_, index) => ({ index, rowKey: index }))}
96
+ rowsVirtualized={false}
97
+ clipIndex={createTimelineClipIndex(tracks)}
98
+ renderTimeRange={{ start: 0, end: Number.POSITIVE_INFINITY }}
99
+ pinnedClipIdentities={new Set()}
92
100
  trackOrder={displayTrackOrder}
93
101
  tracks={tracks}
94
102
  trackStyles={new Map()}
@@ -144,6 +152,7 @@ describe("TimelineLanes track numbering", () => {
144
152
  });
145
153
 
146
154
  expect(visibilityLabels(view.host)).toEqual(["Hide track 1", "Hide track 2"]);
155
+ expect(view.host.querySelectorAll("[data-timeline-row]")).toHaveLength(2);
147
156
  expect(view.host.innerHTML).not.toContain("0.16666666666666666");
148
157
  act(() => view.root.unmount());
149
158
  });
@@ -171,8 +180,10 @@ describe("TimelineLanes track numbering", () => {
171
180
  onContextMenuLane,
172
181
  });
173
182
 
174
- // Row children: [sticky header column, time-mapped track content].
175
- const rows = Array.from(view.host.children);
183
+ // Row children: [sticky header column, time-mapped track content]. The rows
184
+ // sit inside the lanes list, which is what carries the virtualization
185
+ // positioning context.
186
+ const rows = Array.from(view.host.querySelectorAll('[role="listitem"]'));
176
187
  const secondTrackContent = rows[1]?.children.item(1);
177
188
  act(() => {
178
189
  secondTrackContent?.dispatchEvent(
@@ -1,4 +1,4 @@
1
- import { useId } from "react";
1
+ import { Fragment, useId } from "react";
2
2
  import { BeatStrip, BeatBackgroundLines } from "./BeatStrip";
3
3
  import { TimelineClip } from "./TimelineClip";
4
4
  import { TimelineClipDiamonds } from "./TimelineClipDiamonds";
@@ -8,12 +8,14 @@ import { resolveTrackKeyframeClip } from "./useTimelineTrackLayout";
8
8
  import { trackDisplayNumber, trackDisplaySuffix } from "./timelineTrackDisplay";
9
9
  import { clipTimingStart } from "../../hooks/gsapShared";
10
10
  import { getTimelineEditCapabilities, resolveBlockedTimelineEditIntent } from "./timelineEditing";
11
- import { CLIP_Y, CLIP_HANDLE_W, TRACK_H, getTimelineRowHeight } from "./timelineLayout";
11
+ import { CLIP_Y, CLIP_HANDLE_W, TRACK_H } from "./timelineLayout";
12
12
  import { usePlayerStore, type TimelineElement } from "../store/playerStore";
13
13
  import {
14
+ isMultiDragActive,
14
15
  isMultiDragPassenger,
15
- multiDragPassengerOffsetPx,
16
+ multiDragDeltaSeconds,
16
17
  type MultiDragPreviewInput,
18
+ multiDragPassengerOffsetPx,
17
19
  } from "./timelineMultiDragPreview";
18
20
  import type { TimelineLaneBaseProps } from "./timelineLaneProps";
19
21
  import type { TimelineEditCallbacks } from "./timelineCallbacks";
@@ -21,6 +23,10 @@ import { trackStudioKeyframeLaneExpand } from "../../telemetry/events";
21
23
  import { SPLIT_BOUNDARY_EPSILON_S } from "../../utils/timelineElementSplit";
22
24
  import { isAudioTimelineElement, isMusicTrack } from "../../utils/timelineInspector";
23
25
  import { renderClipChildren } from "./timelineClipChildren";
26
+ import { TimelineTrackRow } from "./TimelineTrackRow";
27
+ import { isTimelineClipActive } from "./useTimelineActiveClips";
28
+ import { queryTimelineClipIndex } from "../lib/timelineClipIndex";
29
+ import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
24
30
 
25
31
  interface TimelineLanesProps extends TimelineLaneBaseProps {
26
32
  /** Live-derived by TimelineCanvas from {@link TimelineLaneBaseProps.draggedClip}. */
@@ -41,7 +47,12 @@ export function TimelineLanes({
41
47
  trackContentWidth,
42
48
  theme,
43
49
  displayTrackOrder,
44
- rowHeights,
50
+ rowGeometry,
51
+ virtualRows,
52
+ rowsVirtualized,
53
+ clipIndex,
54
+ renderTimeRange,
55
+ pinnedClipIdentities,
45
56
  trackOrder,
46
57
  tracks,
47
58
  trackStyles,
@@ -102,19 +113,45 @@ export function TimelineLanes({
102
113
  trackStudioKeyframeLaneExpand({ expanded: willExpand });
103
114
  toggleClipExpanded(key);
104
115
  };
116
+ const multiDragDelta =
117
+ multiDragPreview && isMultiDragActive(multiDragPreview)
118
+ ? multiDragDeltaSeconds(multiDragPreview)
119
+ : 0;
120
+ const actorWindows =
121
+ rowsVirtualized && multiDragPreview && multiDragDelta !== 0
122
+ ? [
123
+ {
124
+ range: {
125
+ start: renderTimeRange.start - multiDragDelta,
126
+ end: renderTimeRange.end - multiDragDelta,
127
+ },
128
+ identities: multiDragPreview.selectedKeys,
129
+ },
130
+ ]
131
+ : [];
105
132
  return (
106
- <>
133
+ <div
134
+ role="list"
135
+ aria-label="Timeline tracks"
136
+ className={rowsVirtualized ? "absolute inset-0" : undefined}
137
+ >
107
138
  {
108
- // NOTE (deliberate no-virtualization): lanes and their clips render via a
109
- // plain `.map()` inside the scroll container rather than a windowing/virtualized
110
- // list. NLE clip counts are small (dozens to low hundreds), so the DOM cost is
111
- // bounded and virtualization's complexity isn't worth it. TODO: revisit and swap
112
- // in a virtualizer if editorial workflows ever push very high clip counts.
113
139
  // fallow-ignore-next-line complexity
114
- displayTrackOrder.map((trackNum, row) => {
140
+ virtualRows.map(({ index: row, rowKey }) => {
141
+ const trackNum = displayTrackOrder[row];
142
+ if (trackNum === undefined) return null;
115
143
  const displayNumber = trackDisplayNumber(displayTrackOrder, trackNum);
116
- const rowHeight = getTimelineRowHeight(row, rowHeights);
144
+ const rowHeight = rowGeometry.getRowHeight(row);
117
145
  const els = tracks.find(([t]) => t === trackNum)?.[1] ?? [];
146
+ const renderElements = rowsVirtualized
147
+ ? queryTimelineClipIndex(
148
+ clipIndex,
149
+ trackNum,
150
+ renderTimeRange,
151
+ pinnedClipIdentities,
152
+ actorWindows,
153
+ )
154
+ : els;
118
155
  const ts = trackStyles.get(trackNum) ?? getTrackStyle("");
119
156
  const isPendingTrack =
120
157
  draggedClip?.started === true && !trackOrder.includes(trackNum) && els.length === 0;
@@ -148,14 +185,16 @@ export function TimelineLanes({
148
185
  // fractional sort key and would mint ids like `...-0.16666666666666666`.
149
186
  const lanesId = `${lanesIdPrefix}-track-${row}`;
150
187
  return (
151
- <div
152
- key={trackNum}
153
- className="relative flex"
154
- style={{
155
- height: rowHeight,
156
- background: rowBackground,
157
- borderBottom: `1px solid ${theme.rowBorder}`,
158
- }}
188
+ <TimelineTrackRow
189
+ key={rowKey}
190
+ index={row}
191
+ rowKey={rowKey}
192
+ rowCount={displayTrackOrder.length}
193
+ top={rowGeometry.getRowTop(row)}
194
+ height={rowHeight}
195
+ virtualized={rowsVirtualized}
196
+ background={rowBackground}
197
+ borderColor={theme.rowBorder}
159
198
  >
160
199
  <TimelineTrackHeader
161
200
  trackNumber={trackNum}
@@ -218,6 +257,7 @@ export function TimelineLanes({
218
257
  ? draggedClip.snapTime
219
258
  : null
220
259
  }
260
+ renderTimeRange={rowsVirtualized ? renderTimeRange : undefined}
221
261
  />
222
262
  {/* Beat dots on the active track (the one holding the selection),
223
263
  falling back to the music track when nothing is selected. */}
@@ -226,6 +266,7 @@ export function TimelineLanes({
226
266
  beatTimes={beatAnalysis?.beatTimes}
227
267
  beatStrengths={beatAnalysis?.beatStrengths}
228
268
  pps={pps}
269
+ renderTimeRange={rowsVirtualized ? renderTimeRange : undefined}
229
270
  />
230
271
  )}
231
272
  {isPendingTrack && (
@@ -245,9 +286,9 @@ export function TimelineLanes({
245
286
  )}
246
287
  {
247
288
  // fallow-ignore-next-line complexity
248
- els.map((el) => {
289
+ renderElements.map((el) => {
249
290
  const clipStyle = getTrackStyle(el.tag);
250
- const elementKey = el.key ?? el.id;
291
+ const elementKey = getTimelineElementIdentity(el);
251
292
  // Only the track's active keyframe clip shows expanded lanes;
252
293
  // other clips (incl. siblings on a shared track) show compact
253
294
  // diamonds on their own bar instead.
@@ -263,7 +304,8 @@ export function TimelineLanes({
263
304
  const clipKey = elementKey;
264
305
  const isDraggingClip =
265
306
  draggedClip?.started === true &&
266
- (draggedElement?.key ?? draggedElement?.id) === elementKey;
307
+ draggedElement != null &&
308
+ getTimelineElementIdentity(draggedElement) === elementKey;
267
309
  if (isDraggingClip) return null;
268
310
  const previewElement = getPreviewElement(el);
269
311
  // Passenger of a live multi-drag: slide by the SAME formation
@@ -290,6 +332,7 @@ export function TimelineLanes({
290
332
  isSelected={isSelected}
291
333
  isHovered={hoveredClip === clipKey}
292
334
  isDragging={false}
335
+ isActive={isTimelineClipActive(previewElement, currentTime)}
293
336
  hasCustomContent={!!renderClipContent}
294
337
  capabilities={capabilities}
295
338
  theme={theme}
@@ -434,6 +477,7 @@ export function TimelineLanes({
434
477
  keyframesData={keyframeCache.get(elementKey)!}
435
478
  clipWidthPx={Math.max(previewElement.duration * pps, 4)}
436
479
  clipHeightPx={rowHeight - 2 * CLIP_Y}
480
+ clipDuration={previewElement.duration}
437
481
  beatsActive={beatStripOnTrack}
438
482
  accentColor={clipStyle.accent}
439
483
  isSelected={isSelected}
@@ -499,7 +543,18 @@ export function TimelineLanes({
499
543
  suppressClickRef={suppressClickRef}
500
544
  />
501
545
  );
502
- if (!isPassenger) return [clip, propertyLanes];
546
+ // Keep one keyed top-level child per element. Returning an
547
+ // array here makes React reconcile the outer array by
548
+ // position, so a window shift remounts otherwise stable
549
+ // clip keys and can tear down focus mid-reveal.
550
+ if (!isPassenger) {
551
+ return (
552
+ <Fragment key={clipKey}>
553
+ {clip}
554
+ {propertyLanes}
555
+ </Fragment>
556
+ );
557
+ }
503
558
  return (
504
559
  <div
505
560
  key={clipKey}
@@ -518,10 +573,10 @@ export function TimelineLanes({
518
573
  })
519
574
  }
520
575
  </div>
521
- </div>
576
+ </TimelineTrackRow>
522
577
  );
523
578
  })
524
579
  }
525
- </>
580
+ </div>
526
581
  );
527
582
  }
@@ -126,6 +126,19 @@ const POSITION_SEGMENT_ANIMATION = animation("position-tween", "position", [
126
126
  { percentage: 50, properties: { x: 50 } },
127
127
  ]);
128
128
 
129
+ function boundaryKeyframeAnimations(): GsapAnimation[] {
130
+ return [
131
+ animation("position-tween", "position", [
132
+ { percentage: 0, properties: { x: 0 } },
133
+ { percentage: 100, properties: { x: 100 } },
134
+ ]),
135
+ animation("visual-tween", "visual", [
136
+ { percentage: 0, properties: { opacity: 0 } },
137
+ { percentage: 100, properties: { opacity: 1 } },
138
+ ]),
139
+ ];
140
+ }
141
+
129
142
  /** A tween the parser leaves unclassified because it spans several groups. */
130
143
  function ungroupedAnimation(
131
144
  id: string,
@@ -306,16 +319,7 @@ describe("TimelinePropertyLanes", () => {
306
319
  });
307
320
 
308
321
  it("keeps both groups' diamonds when their source keyframes share 0% and 100%", () => {
309
- const animations = [
310
- animation("position-tween", "position", [
311
- { percentage: 0, properties: { x: 0 } },
312
- { percentage: 100, properties: { x: 100 } },
313
- ]),
314
- animation("visual-tween", "visual", [
315
- { percentage: 0, properties: { opacity: 0 } },
316
- { percentage: 100, properties: { opacity: 1 } },
317
- ]),
318
- ];
322
+ const animations = boundaryKeyframeAnimations();
319
323
 
320
324
  const { host, root } = renderPropertyLanes({ animations });
321
325
 
@@ -512,16 +516,7 @@ describe("TimelinePropertyLanes", () => {
512
516
  // COLUMN whose children are all absolutely positioned: it computed to 0x0 and
513
517
  // held no diamonds. The real lanes had no wrapper at all to point at.
514
518
  it("wraps the lanes in the identified element so aria-controls resolves to the diamonds", () => {
515
- const animations = [
516
- animation("position-tween", "position", [
517
- { percentage: 0, properties: { x: 0 } },
518
- { percentage: 100, properties: { x: 100 } },
519
- ]),
520
- animation("visual-tween", "visual", [
521
- { percentage: 0, properties: { opacity: 0 } },
522
- { percentage: 100, properties: { opacity: 1 } },
523
- ]),
524
- ];
519
+ const animations = boundaryKeyframeAnimations();
525
520
  const { host, root } = renderPropertyLanes({ id: "timeline-lanes-track-0", animations });
526
521
  const wrapper = host.querySelector("#timeline-lanes-track-0");
527
522
 
@@ -594,6 +589,7 @@ describe("TimelinePropertyLanes", () => {
594
589
  }}
595
590
  clipWidthPx={200}
596
591
  clipHeightPx={48}
592
+ clipDuration={10}
597
593
  accentColor="#4ba3d2"
598
594
  isSelected
599
595
  currentPercentage={-10}
@@ -240,6 +240,7 @@ export function TimelinePropertyLanes({
240
240
  keyframesData={keyframesData}
241
241
  clipWidthPx={clipWidthPx}
242
242
  clipHeightPx={LANE_H}
243
+ clipDuration={clipDuration}
243
244
  accentColor={accentColor}
244
245
  isSelected={isSelected}
245
246
  currentPercentage={currentPercentage}
@@ -1,9 +1,11 @@
1
1
  import { memo } from "react";
2
2
  import type { TimelineTheme } from "./timelineTheme";
3
- import { RULER_H, formatTimelineTickLabel } from "./timelineLayout";
3
+ import { RULER_H, getTimelineBeatEntries } from "./timelineLayout";
4
+ import { formatTimelineTickLabel } from "./timelineRulerGeometry";
4
5
  import { usePlayerStore } from "../store/playerStore";
5
6
  import { secondsToFrame } from "../lib/time";
6
7
  import type { MusicBeatAnalysis } from "@hyperframes/core/beats";
8
+ import type { TimelineTimeRange } from "../lib/timelineClipIndex";
7
9
 
8
10
  interface TimelineRulerProps {
9
11
  major: number[];
@@ -16,6 +18,7 @@ interface TimelineRulerProps {
16
18
  theme: TimelineTheme;
17
19
  beatAnalysis?: MusicBeatAnalysis | null;
18
20
  contentOrigin: number;
21
+ renderTimeRange?: TimelineTimeRange;
19
22
  }
20
23
 
21
24
  export const TimelineRuler = memo(function TimelineRuler({
@@ -29,10 +32,12 @@ export const TimelineRuler = memo(function TimelineRuler({
29
32
  theme,
30
33
  beatAnalysis,
31
34
  contentOrigin,
35
+ renderTimeRange,
32
36
  }: TimelineRulerProps) {
33
37
  const timeDisplayMode = usePlayerStore((s) => s.timeDisplayMode);
34
38
  const beatTimes = beatAnalysis?.beatTimes ?? [];
35
39
  const beatStrengths = beatAnalysis?.beatStrengths ?? [];
40
+ const beatEntries = getTimelineBeatEntries(beatTimes, beatStrengths, renderTimeRange);
36
41
 
37
42
  // Only draw beat lines when they'd be at least 5px apart
38
43
  const avgBeatInterval =
@@ -51,13 +56,14 @@ export const TimelineRuler = memo(function TimelineRuler({
51
56
  height={totalH}
52
57
  >
53
58
  {showBeats &&
54
- beatTimes.map((t, i) => {
59
+ beatEntries.map(({ time: t, index: i, strength: beatStrength }) => {
55
60
  const x = t * pps;
56
61
  // Louder beats → brighter line. Gamma curve widens the contrast.
57
- const strength = Math.pow(Math.min(1, beatStrengths[i] ?? 0.5), 2.2);
62
+ const strength = Math.pow(Math.min(1, beatStrength ?? 0.5), 2.2);
58
63
  const opacity = 0.08 + strength * 0.62;
59
64
  return (
60
65
  <line
66
+ data-timeline-grid-cell="beat"
61
67
  key={`b-${t}-${i}`}
62
68
  x1={x}
63
69
  y1={0}
@@ -103,13 +109,23 @@ export const TimelineRuler = memo(function TimelineRuler({
103
109
  contentOrigin + t * pps (see getTimelinePlayheadLeft). Without the shift
104
110
  a tick spans [x, x+1) and its center is half a pixel right. */}
105
111
  {minor.map((t) => (
106
- <div key={`m-${t}`} className="absolute bottom-0" style={{ left: t * pps - 0.5 }}>
112
+ <div
113
+ key={`m-${t}`}
114
+ data-timeline-grid-cell="minor"
115
+ className="absolute bottom-0"
116
+ style={{ left: t * pps - 0.5 }}
117
+ >
107
118
  <div className="w-px h-2" style={{ background: theme.tickMinor }} />
108
119
  </div>
109
120
  ))}
110
121
 
111
122
  {major.map((t) => (
112
- <div key={`M-${t}`} className="absolute top-0" style={{ left: t * pps - 0.5 }}>
123
+ <div
124
+ key={`M-${t}`}
125
+ data-timeline-grid-cell="major"
126
+ className="absolute top-0"
127
+ style={{ left: t * pps - 0.5 }}
128
+ >
113
129
  <span
114
130
  className="absolute font-mono tabular-nums leading-none whitespace-nowrap"
115
131
  style={{
@@ -0,0 +1,46 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ interface TimelineTrackRowProps {
4
+ index: number;
5
+ rowKey: number;
6
+ rowCount: number;
7
+ top: number;
8
+ height: number;
9
+ virtualized: boolean;
10
+ background: string;
11
+ borderColor: string;
12
+ children: ReactNode;
13
+ }
14
+
15
+ /** Accessible row shell; edit geometry owns its exact top and height. */
16
+ export function TimelineTrackRow({
17
+ index,
18
+ rowKey,
19
+ rowCount,
20
+ top,
21
+ height,
22
+ virtualized,
23
+ background,
24
+ borderColor,
25
+ children,
26
+ }: TimelineTrackRowProps) {
27
+ return (
28
+ <div
29
+ role="listitem"
30
+ aria-posinset={index + 1}
31
+ aria-setsize={rowCount}
32
+ data-index={index}
33
+ data-timeline-row={index}
34
+ data-timeline-row-key={rowKey}
35
+ className={`${virtualized ? "absolute left-0 right-0" : "relative"} flex`}
36
+ style={{
37
+ top: virtualized ? top : undefined,
38
+ height,
39
+ background,
40
+ borderBottom: `1px solid ${borderColor}`,
41
+ }}
42
+ >
43
+ {children}
44
+ </div>
45
+ );
46
+ }
@@ -5,6 +5,8 @@ import type { TimelineTheme } from "./timelineTheme";
5
5
  import type { TimelineEditOverrides } from "./useResolvedTimelineEditCallbacks";
6
6
 
7
7
  export interface TimelineProps extends TimelineDropCallbacks, TimelineEditOverrides {
8
+ /** Project-scoped reset boundary; soft source refreshes retain the same epoch. */
9
+ sessionEpoch?: number;
8
10
  onSeek?: (time: number) => void;
9
11
  onDrillDown?: (element: TimelineElement) => void;
10
12
  renderClipContent?: (