@hyperframes/studio 0.7.86 → 0.7.87

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/dist/assets/{hyperframes-player-BL3qfGEQ.js → hyperframes-player-BFqB5AWJ.js} +1 -1
  2. package/dist/assets/{index-B5QGai9B.js → index-B1Vf4vCl.js} +1 -1
  3. package/dist/assets/index-BXkzp_OU.css +1 -0
  4. package/dist/assets/{index-CISVI7UC.js → index-BblzZ6Av.js} +221 -221
  5. package/dist/assets/{index-C0Vr8jjc.js → index-D_JU4EeH.js} +1 -1
  6. package/dist/index.d.ts +14 -6
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +6265 -4922
  9. package/dist/index.js.map +1 -1
  10. package/package.json +11 -8
  11. package/src/components/TimelineToolbar.tsx +14 -5
  12. package/src/components/nle/NLEContext.tsx +4 -1
  13. package/src/components/nle/TimelinePane.tsx +2 -0
  14. package/src/hooks/studioTestMode.test.ts +35 -0
  15. package/src/hooks/studioTestMode.ts +23 -0
  16. package/src/hooks/useGestureRecording.test.tsx +120 -0
  17. package/src/hooks/useGestureRecording.ts +49 -40
  18. package/src/hooks/useStudioTestHooks.test.tsx +161 -0
  19. package/src/hooks/useStudioTestHooks.ts +54 -7
  20. package/src/player/components/BeatStrip.tsx +21 -5
  21. package/src/player/components/CompositionThumbnail.test.ts +96 -2
  22. package/src/player/components/CompositionThumbnail.tsx +29 -16
  23. package/src/player/components/Timeline.test.ts +104 -0
  24. package/src/player/components/Timeline.tsx +141 -131
  25. package/src/player/components/Timeline.virtualization.test.tsx +400 -0
  26. package/src/player/components/TimelineCanvas.tsx +18 -10
  27. package/src/player/components/TimelineClip.tsx +4 -0
  28. package/src/player/components/TimelineClipDiamonds.test.tsx +85 -5
  29. package/src/player/components/TimelineClipDiamonds.tsx +53 -13
  30. package/src/player/components/TimelineLanes.test.tsx +15 -4
  31. package/src/player/components/TimelineLanes.tsx +81 -26
  32. package/src/player/components/TimelinePropertyLanes.test.tsx +16 -20
  33. package/src/player/components/TimelinePropertyLanes.tsx +1 -0
  34. package/src/player/components/TimelineRuler.tsx +21 -5
  35. package/src/player/components/TimelineTrackRow.tsx +46 -0
  36. package/src/player/components/TimelineTypes.ts +2 -0
  37. package/src/player/components/timelineClipDragGestureLifecycle.test.ts +97 -0
  38. package/src/player/components/timelineClipDragGestureLifecycle.ts +261 -0
  39. package/src/player/components/timelineDiamondTypes.ts +5 -0
  40. package/src/player/components/timelineDragDrop.ts +5 -5
  41. package/src/player/components/timelineEditing.test.ts +32 -0
  42. package/src/player/components/timelineEditing.ts +47 -5
  43. package/src/player/components/timelineFocusIdentity.test.ts +21 -0
  44. package/src/player/components/timelineFocusIdentity.ts +21 -0
  45. package/src/player/components/timelineLaneProps.ts +9 -0
  46. package/src/player/components/timelineLayout.test.ts +59 -0
  47. package/src/player/components/timelineLayout.ts +202 -176
  48. package/src/player/components/timelineRowVirtualizationFlag.test.ts +23 -0
  49. package/src/player/components/timelineRowVirtualizationFlag.ts +10 -0
  50. package/src/player/components/timelineRulerGeometry.ts +142 -0
  51. package/src/player/components/timelineViewModel.ts +44 -0
  52. package/src/player/components/timelineViewportGeometry.ts +51 -0
  53. package/src/player/components/timelineZoom.test.ts +42 -28
  54. package/src/player/components/timelineZoom.ts +42 -25
  55. package/src/player/components/useTimelineActiveClips.test.ts +82 -79
  56. package/src/player/components/useTimelineActiveClips.ts +49 -55
  57. package/src/player/components/useTimelineClipDrag.ts +40 -230
  58. package/src/player/components/useTimelineClipRenderWindow.ts +95 -0
  59. package/src/player/components/useTimelineGeometry.ts +4 -11
  60. package/src/player/components/useTimelinePlayhead.ts +30 -14
  61. package/src/player/components/useTimelineRangeSelection.ts +5 -4
  62. package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +3 -2
  63. package/src/player/components/useTimelineRevealClip.test.tsx +139 -0
  64. package/src/player/components/useTimelineRevealClip.ts +171 -47
  65. package/src/player/components/useTimelineRowVirtualization.ts +140 -0
  66. package/src/player/components/useTimelineScrollViewport.test.tsx +202 -0
  67. package/src/player/components/useTimelineScrollViewport.ts +87 -9
  68. package/src/player/components/useTimelineSelectionLifecycle.ts +29 -0
  69. package/src/player/components/useTimelineShiftModifier.ts +20 -0
  70. package/src/player/components/useTimelineTicks.ts +22 -0
  71. package/src/player/components/useTimelineTrackLayout.test.ts +32 -26
  72. package/src/player/components/useTimelineTrackLayout.ts +35 -18
  73. package/src/player/components/useTimelineVirtualRows.test.tsx +174 -0
  74. package/src/player/components/useTimelineVirtualRows.ts +126 -0
  75. package/src/player/hooks/timelinePlayerSync.ts +20 -0
  76. package/src/player/hooks/useTimelinePlayer.seek.test.ts +90 -51
  77. package/src/player/hooks/useTimelinePlayer.ts +8 -20
  78. package/src/player/lib/playbackScrub.ts +2 -2
  79. package/src/player/lib/timelineClipIndex.test.ts +169 -0
  80. package/src/player/lib/timelineClipIndex.ts +208 -0
  81. package/src/player/lib/timelineElementIndexes.test.ts +31 -0
  82. package/src/player/lib/timelineElementIndexes.ts +44 -0
  83. package/src/player/lib/timelinePerformanceDiagnostics.test.ts +100 -0
  84. package/src/player/lib/timelinePerformanceDiagnostics.ts +120 -0
  85. package/src/player/lib/timelinePerformanceFixture.ts +167 -0
  86. package/src/player/lib/timelineViewportBudgets.test.ts +60 -0
  87. package/src/player/lib/timelineViewportBudgets.ts +151 -0
  88. package/src/player/store/playerStore.test.ts +20 -3
  89. package/src/player/store/playerStore.ts +73 -45
  90. package/src/utils/studioHelpers.test.ts +48 -1
  91. package/src/utils/studioHelpers.ts +31 -34
  92. package/src/utils/studioUiPreferences.ts +3 -1
  93. package/dist/assets/index-D379YOKT.css +0 -1
@@ -0,0 +1,400 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
6
+
7
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
8
+
9
+ class MockResizeObserver {
10
+ constructor(private readonly callback: ResizeObserverCallback) {}
11
+ observe(target: Element) {
12
+ this.callback(
13
+ [
14
+ {
15
+ target,
16
+ borderBoxSize: [{ inlineSize: target.clientWidth, blockSize: target.clientHeight }],
17
+ } as unknown as ResizeObserverEntry,
18
+ ],
19
+ this as unknown as ResizeObserver,
20
+ );
21
+ }
22
+ unobserve() {}
23
+ disconnect() {}
24
+ }
25
+
26
+ const originalResizeObserver = globalThis.ResizeObserver;
27
+ const originalClientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "clientWidth");
28
+ const originalClientHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "clientHeight");
29
+ let clientWidth = 900;
30
+ let clientHeight = 240;
31
+
32
+ beforeAll(() => {
33
+ vi.stubEnv("VITE_STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED", "1");
34
+ globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
35
+ Object.defineProperty(HTMLElement.prototype, "clientWidth", {
36
+ configurable: true,
37
+ get: () => clientWidth,
38
+ });
39
+ Object.defineProperty(HTMLElement.prototype, "clientHeight", {
40
+ configurable: true,
41
+ get: () => clientHeight,
42
+ });
43
+ });
44
+
45
+ beforeEach(() => {
46
+ clientWidth = 900;
47
+ clientHeight = 240;
48
+ });
49
+
50
+ afterAll(() => {
51
+ vi.unstubAllEnvs();
52
+ globalThis.ResizeObserver = originalResizeObserver;
53
+ if (originalClientWidth)
54
+ Object.defineProperty(HTMLElement.prototype, "clientWidth", originalClientWidth);
55
+ if (originalClientHeight)
56
+ Object.defineProperty(HTMLElement.prototype, "clientHeight", originalClientHeight);
57
+ document.body.innerHTML = "";
58
+ });
59
+
60
+ /**
61
+ * The virtualized list only mounts rows/clips after its ResizeObserver and the
62
+ * follow-up layout effect have both flushed, which is more than one React tick.
63
+ * A fixed number of flushes is a coin flip once the rest of the suite is
64
+ * competing for workers, so wait for the DOM the assertions actually need.
65
+ */
66
+ async function settleUntil(predicate: () => boolean, tries = 60): Promise<void> {
67
+ for (let attempt = 0; attempt < tries; attempt++) {
68
+ if (predicate()) return;
69
+ await act(async () => {
70
+ await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
71
+ });
72
+ }
73
+ }
74
+
75
+ async function advanceFrame(): Promise<void> {
76
+ await act(async () => {
77
+ await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
78
+ });
79
+ }
80
+
81
+ async function mountTimeline(element: React.ReactElement) {
82
+ const host = document.createElement("div");
83
+ document.body.append(host);
84
+ const root = createRoot(host);
85
+ await act(async () => root.render(element));
86
+ await act(async () => {});
87
+ return { host, root };
88
+ }
89
+
90
+ async function dispatchScroll(scroller: HTMLElement): Promise<void> {
91
+ await act(async () => {
92
+ scroller.dispatchEvent(new Event("scroll"));
93
+ await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
94
+ });
95
+ }
96
+
97
+ function clipsByTrack(count: number, duration = 1, includeLabels = false) {
98
+ return Array.from({ length: count }, (_, track) => ({
99
+ id: `clip-${track}`,
100
+ ...(includeLabels ? { label: `Clip ${track}` } : {}),
101
+ tag: "div",
102
+ start: 0,
103
+ duration,
104
+ track,
105
+ }));
106
+ }
107
+
108
+ async function scrollTimelineHorizontally(
109
+ scroller: HTMLElement,
110
+ scrollLeft: number,
111
+ ): Promise<void> {
112
+ scroller.scrollLeft = scrollLeft;
113
+ await dispatchScroll(scroller);
114
+ }
115
+
116
+ // These tests mount 500-10,000 timeline elements and settle the virtualizer,
117
+ // which lands right on the 5s default once the rest of the suite is competing
118
+ // for workers. The generous ceiling is a flake guard, not an expected runtime.
119
+ describe("Timeline row virtualization", { timeout: 30_000 }, () => {
120
+ it("keeps a zero-size first render bounded while the feature flag is enabled", async () => {
121
+ clientWidth = 0;
122
+ clientHeight = 0;
123
+ const [{ Timeline }, { usePlayerStore }] = await Promise.all([
124
+ import("./Timeline"),
125
+ import("../store/playerStore"),
126
+ ]);
127
+ usePlayerStore.setState({
128
+ duration: 60,
129
+ timelineReady: true,
130
+ elements: clipsByTrack(10_000),
131
+ });
132
+
133
+ const { host, root } = await mountTimeline(React.createElement(Timeline, { sessionEpoch: 2 }));
134
+
135
+ const rows = host.querySelectorAll('[role="listitem"]');
136
+ expect(rows.length).toBeGreaterThan(0);
137
+ expect(rows.length).toBeLessThanOrEqual(16);
138
+
139
+ act(() => root.unmount());
140
+ usePlayerStore.getState().reset();
141
+ });
142
+
143
+ it("defers rich clip content while scrolling without replacing the clip shell", async () => {
144
+ const [{ Timeline }, { usePlayerStore }] = await Promise.all([
145
+ import("./Timeline"),
146
+ import("../store/playerStore"),
147
+ ]);
148
+ usePlayerStore.setState({
149
+ duration: 60,
150
+ timelineReady: true,
151
+ selectedElementId: "clip-0",
152
+ elements: [{ id: "clip-0", label: "Clip 0", tag: "div", start: 0, duration: 10, track: 0 }],
153
+ });
154
+
155
+ const { host, root } = await mountTimeline(
156
+ React.createElement(Timeline, {
157
+ renderClipContent: () => React.createElement("span", { "data-rich-content": true }),
158
+ }),
159
+ );
160
+ try {
161
+ await act(async () => {
162
+ await new Promise((resolve) => setTimeout(resolve, 110));
163
+ });
164
+
165
+ const scroller = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]");
166
+ const clip = host.querySelector<HTMLElement>('[data-el-id="clip-0"]');
167
+ expect(scroller).not.toBeNull();
168
+ expect(clip).not.toBeNull();
169
+ expect(clip?.title).toBe("Clip 0 • 0.0s – 10.0s");
170
+ expect(host.querySelector("[data-rich-content]")).not.toBeNull();
171
+
172
+ if (scroller) await dispatchScroll(scroller);
173
+ expect(host.querySelector('[data-el-id="clip-0"]')).toBe(clip);
174
+ expect(host.querySelector("[data-rich-content]")).toBeNull();
175
+
176
+ await act(async () => {
177
+ await new Promise((resolve) => setTimeout(resolve, 110));
178
+ });
179
+ expect(host.querySelector('[data-el-id="clip-0"]')).toBe(clip);
180
+ expect(host.querySelector("[data-rich-content]")).not.toBeNull();
181
+ } finally {
182
+ act(() => root.unmount());
183
+ usePlayerStore.getState().reset();
184
+ }
185
+ });
186
+
187
+ it("mounts a bounded list range over the full geometry height", async () => {
188
+ const [{ Timeline }, { usePlayerStore }, { getTimelineCanvasHeight, TRACK_H }] =
189
+ await Promise.all([
190
+ import("./Timeline"),
191
+ import("../store/playerStore"),
192
+ import("./timelineLayout"),
193
+ ]);
194
+ usePlayerStore.setState({
195
+ duration: 60,
196
+ timelineReady: true,
197
+ elements: clipsByTrack(1_000),
198
+ });
199
+
200
+ const { host, root } = await mountTimeline(React.createElement(Timeline, { sessionEpoch: 3 }));
201
+
202
+ await settleUntil(
203
+ () =>
204
+ (host.querySelector('[role="list"]')?.querySelectorAll('[role="listitem"]').length ?? 0) >
205
+ 0,
206
+ );
207
+ const list = host.querySelector<HTMLElement>('[role="list"]');
208
+ const rows = list?.querySelectorAll('[role="listitem"]') ?? [];
209
+ expect(rows.length).toBeGreaterThan(0);
210
+ expect(rows.length).toBeLessThanOrEqual(16);
211
+ expect(rows[0]?.getAttribute("aria-posinset")).toBe("1");
212
+ expect(rows[0]?.getAttribute("aria-setsize")).toBe("1000");
213
+ expect(list?.parentElement?.style.height).toBe(
214
+ `${getTimelineCanvasHeight(Array.from({ length: 1_000 }, () => TRACK_H))}px`,
215
+ );
216
+
217
+ const firstRow = rows[0] as HTMLElement;
218
+ const focusedControl = firstRow.querySelector<HTMLButtonElement>("button");
219
+ expect(focusedControl).not.toBeNull();
220
+ act(() => focusedControl?.focus());
221
+ const scroller = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]");
222
+ expect(scroller).not.toBeNull();
223
+ if (scroller) {
224
+ scroller.scrollTop = 500 * 48;
225
+ await act(async () => {
226
+ scroller.dispatchEvent(new Event("scroll"));
227
+ });
228
+ }
229
+ expect(list?.querySelector('[data-timeline-row-key="0"]')).not.toBeNull();
230
+ expect(document.activeElement).toBe(focusedControl);
231
+
232
+ act(() => root.unmount());
233
+ usePlayerStore.getState().reset();
234
+ });
235
+
236
+ it("windows clips and ruler cells while retaining an off-window selected clip", async () => {
237
+ const [{ Timeline }, { usePlayerStore }, { TIMELINE_VIEWPORT_BUDGETS }] = await Promise.all([
238
+ import("./Timeline"),
239
+ import("../store/playerStore"),
240
+ import("../lib/timelineViewportBudgets"),
241
+ ]);
242
+ usePlayerStore.setState({
243
+ duration: 1_000,
244
+ timelineReady: true,
245
+ zoomMode: "manual",
246
+ manualZoomPercent: 2_000,
247
+ selectedElementId: "clip-490",
248
+ selectedElementIds: new Set(["clip-490"]),
249
+ // Repeated fixture shape intentionally contrasts row and clip windowing scales.
250
+ // fallow-ignore-next-line code-duplication
251
+ elements: Array.from({ length: 500 }, (_, index) => ({
252
+ id: `clip-${index}`,
253
+ tag: "div",
254
+ start: index * 2,
255
+ duration: 1,
256
+ track: 0,
257
+ })),
258
+ });
259
+
260
+ const host = document.createElement("div");
261
+ document.body.append(host);
262
+ const root = createRoot(host);
263
+ await act(async () => root.render(React.createElement(Timeline, { sessionEpoch: 4 })));
264
+ await act(async () => {});
265
+
266
+ await settleUntil(() => host.querySelectorAll("[data-clip]").length > 1);
267
+ const initialClips = [...host.querySelectorAll<HTMLElement>("[data-clip]")];
268
+ const initialGridCells = host.querySelectorAll("[data-timeline-grid-cell]");
269
+ expect(initialClips.length).toBeGreaterThan(1);
270
+ expect(initialClips.length).toBeLessThanOrEqual(
271
+ TIMELINE_VIEWPORT_BUDGETS.maxMountedClipRootsPerRow + 1,
272
+ );
273
+ expect(initialGridCells.length).toBeLessThan(100);
274
+ expect(host.querySelector('[data-el-id="clip-490"]')).not.toBeNull();
275
+ const initialWindowIds = initialClips.map((clip) => clip.dataset.elId);
276
+
277
+ const scroller = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]");
278
+ expect(scroller).not.toBeNull();
279
+ if (scroller) await scrollTimelineHorizontally(scroller, 8_000);
280
+
281
+ const scrolledClips = [...host.querySelectorAll<HTMLElement>("[data-clip]")];
282
+ expect(scrolledClips.map((clip) => clip.dataset.elId)).not.toEqual(initialWindowIds);
283
+ expect(scrolledClips.length).toBeLessThanOrEqual(
284
+ TIMELINE_VIEWPORT_BUDGETS.maxMountedClipRootsPerRow + 1,
285
+ );
286
+ expect(host.querySelector('[data-el-id="clip-490"]')).not.toBeNull();
287
+ expect(host.querySelectorAll("[data-timeline-grid-cell]").length).toBeLessThan(100);
288
+
289
+ await act(async () => usePlayerStore.getState().requestClipReveal("clip-300"));
290
+ await advanceFrame();
291
+ await act(async () => {});
292
+ expect(usePlayerStore.getState().clipRevealRequest).toBeNull();
293
+ const focusedClip = host.querySelector('[data-el-id="clip-300"]');
294
+ expect(document.activeElement).toBe(focusedClip);
295
+ await advanceFrame();
296
+ expect(host.querySelector('[data-el-id="clip-300"]')).toBe(focusedClip);
297
+ expect(document.activeElement).toBe(focusedClip);
298
+ expect(host.querySelector('[data-el-id="clip-490"]')).not.toBeNull();
299
+
300
+ if (scroller) await scrollTimelineHorizontally(scroller, 0);
301
+ expect(host.querySelector('[data-el-id="clip-300"]')).toBe(focusedClip);
302
+ expect(document.activeElement).toBe(focusedClip);
303
+
304
+ act(() => root.unmount());
305
+ usePlayerStore.getState().reset();
306
+ });
307
+ });
308
+
309
+ /**
310
+ * The rollback build mounts every clip, so the scroll-time concessions
311
+ * windowing makes are pure cost there. This block pins that explicit fallback
312
+ * to doing no per-frame work while a gesture runs.
313
+ */
314
+ describe("Timeline without row virtualization", { timeout: 30_000 }, () => {
315
+ async function renderUnvirtualizedTimeline() {
316
+ vi.stubEnv("VITE_STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED", "0");
317
+ vi.resetModules();
318
+ const [{ Timeline }, { usePlayerStore }] = await Promise.all([
319
+ import("./Timeline"),
320
+ import("../store/playerStore"),
321
+ ]);
322
+ usePlayerStore.setState({
323
+ duration: 60,
324
+ timelineReady: true,
325
+ selectedElementId: "clip-0",
326
+ elements: clipsByTrack(40, 10, true),
327
+ });
328
+
329
+ const { host, root } = await mountTimeline(
330
+ React.createElement(Timeline, {
331
+ renderClipContent: () => React.createElement("span", { "data-rich-content": true }),
332
+ }),
333
+ );
334
+ return {
335
+ host,
336
+ dispose: () => {
337
+ act(() => root.unmount());
338
+ usePlayerStore.getState().reset();
339
+ vi.stubEnv("VITE_STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED", "1");
340
+ vi.resetModules();
341
+ },
342
+ };
343
+ }
344
+
345
+ it("mounts every clip rather than a window", async () => {
346
+ const { host, dispose } = await renderUnvirtualizedTimeline();
347
+ try {
348
+ expect(host.querySelectorAll("[data-clip]").length).toBe(40);
349
+ } finally {
350
+ dispose();
351
+ }
352
+ });
353
+
354
+ it("keeps clip content mounted across a scroll gesture", async () => {
355
+ const { host, dispose } = await renderUnvirtualizedTimeline();
356
+ try {
357
+ const scroller = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]");
358
+ expect(scroller).not.toBeNull();
359
+ const richBefore = host.querySelectorAll("[data-rich-content]").length;
360
+ expect(richBefore).toBe(40);
361
+
362
+ if (scroller) await dispatchScroll(scroller);
363
+
364
+ expect(host.querySelectorAll("[data-rich-content]").length).toBe(richBefore);
365
+ } finally {
366
+ dispose();
367
+ }
368
+ });
369
+
370
+ it("does not swap clip content back in after the gesture settles", async () => {
371
+ const { host, dispose } = await renderUnvirtualizedTimeline();
372
+ try {
373
+ const scroller = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]");
374
+ const clip = host.querySelector<HTMLElement>('[data-el-id="clip-0"]');
375
+ if (scroller) await dispatchScroll(scroller);
376
+ await act(async () => {
377
+ await new Promise((resolve) => setTimeout(resolve, 150));
378
+ });
379
+
380
+ expect(host.querySelector('[data-el-id="clip-0"]')).toBe(clip);
381
+ expect(host.querySelectorAll("[data-rich-content]").length).toBe(40);
382
+ } finally {
383
+ dispose();
384
+ }
385
+ });
386
+
387
+ it("leaves the scroll position alone, so no snapshot round trip happens", async () => {
388
+ const { host, dispose } = await renderUnvirtualizedTimeline();
389
+ try {
390
+ const scroller = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]");
391
+ if (!scroller) throw new Error("Expected a timeline scroll viewport");
392
+ scroller.scrollTop = 400;
393
+ await dispatchScroll(scroller);
394
+
395
+ expect(scroller.scrollTop).toBe(400);
396
+ } finally {
397
+ dispose();
398
+ }
399
+ });
400
+ });
@@ -23,8 +23,9 @@ import { TimelineClip } from "./TimelineClip";
23
23
  import { TimelineLanes } from "./TimelineLanes";
24
24
  import type { TimelineLaneBaseProps } from "./timelineLaneProps";
25
25
  import { renderClipChildren } from "./timelineClipChildren";
26
- import { useTimelineRevealClip } from "./useTimelineRevealClip";
27
26
  import type { TimelineLaneGapStrips } from "./useTimelineGapHighlights";
27
+ import { isTimelineClipActive } from "./useTimelineActiveClips";
28
+ import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
28
29
 
29
30
  interface TimelineCanvasProps extends TimelineLaneBaseProps {
30
31
  major: number[];
@@ -62,14 +63,13 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
62
63
  onRazorSplitAll,
63
64
  } = useTimelineEditContextOptional();
64
65
  const beatDragging = usePlayerStore((s) => s.beatDragging);
65
- // Scroll a clip into view when the sidebar (asset card) requests a reveal.
66
- useTimelineRevealClip(scrollRef);
67
66
  const draggedElement = draggedClip?.element ?? null;
67
+ const draggedElementIdentity = draggedElement ? getTimelineElementIdentity(draggedElement) : null;
68
68
  const activeDraggedElement =
69
- draggedClip?.started === true && draggedElement
69
+ draggedClip?.started === true && draggedElement && draggedElementIdentity
70
70
  ? getRenderedTimelineElement({
71
71
  element: draggedElement,
72
- draggedElementId: draggedElement.key ?? draggedElement.id,
72
+ draggedElementId: draggedElementIdentity,
73
73
  previewStart: draggedClip.previewStart,
74
74
  previewTrack: draggedClip.previewTrack,
75
75
  })
@@ -85,10 +85,10 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
85
85
  // the wall together and never deforms. Matches what the commit will do — see
86
86
  // timelineMultiDragPreview + commit.
87
87
  const multiDragPreview: MultiDragPreviewInput | null =
88
- draggedClip?.started === true && draggedElement
88
+ draggedClip?.started === true && draggedElement && draggedElementIdentity
89
89
  ? {
90
90
  dragStarted: true,
91
- draggedKey: draggedElement.key ?? draggedElement.id,
91
+ draggedKey: draggedElementIdentity,
92
92
  draggedOriginStart: draggedElement.start,
93
93
  draggedPreviewStart: draggedClip.previewStart,
94
94
  selectedKeys: selectedElementIds,
@@ -126,11 +126,12 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
126
126
  theme={props.theme}
127
127
  beatAnalysis={props.beatAnalysis}
128
128
  contentOrigin={props.contentOrigin}
129
+ renderTimeRange={props.rowsVirtualized ? props.renderTimeRange : undefined}
129
130
  />
130
131
 
131
132
  {/* Breathing room between the sticky ruler and the first track lane — the
132
133
  top half of the CapCut-style padding (see TRACKS_TOP_PAD). */}
133
- <div aria-hidden="true" style={{ height: TRACKS_TOP_PAD }} />
134
+ <div aria-hidden="true" style={{ height: props.rowsVirtualized ? 0 : TRACKS_TOP_PAD }} />
134
135
 
135
136
  <TimelineLanes
136
137
  {...props}
@@ -147,7 +148,7 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
147
148
  {/* Breathing room below the last track lane (~1.5 track heights) — a real
148
149
  scrollable surface, so a clip can be dragged into the void to create a
149
150
  new bottom track comfortably (see TRACKS_BOTTOM_PAD / getTimelineCanvasHeight). */}
150
- <div aria-hidden="true" style={{ height: TRACKS_BOTTOM_PAD }} />
151
+ <div aria-hidden="true" style={{ height: props.rowsVirtualized ? 0 : TRACKS_BOTTOM_PAD }} />
151
152
 
152
153
  {/* Gap strips — loud dashed fill for the gap(s) a hovered "Close gap(s)"
153
154
  menu row would collapse; a quiet tint for every gap on the selected
@@ -157,7 +158,13 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
157
158
  const rowIndex = displayTrackOrder.indexOf(strip.track);
158
159
  if (rowIndex < 0) return null;
159
160
  const loud = strip.kind === "hover";
160
- return strip.intervals.map((gap) => (
161
+ const visibleIntervals = props.rowsVirtualized
162
+ ? strip.intervals.filter(
163
+ (gap) =>
164
+ gap.start < props.renderTimeRange.end && gap.end > props.renderTimeRange.start,
165
+ )
166
+ : strip.intervals;
167
+ return visibleIntervals.map((gap) => (
161
168
  <div
162
169
  key={`gap-${strip.kind}-${strip.track}-${gap.start}`}
163
170
  className="pointer-events-none absolute"
@@ -249,6 +256,7 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
249
256
  }
250
257
  isHovered={false}
251
258
  isDragging={true}
259
+ isActive={isTimelineClipActive(activeDraggedElement, props.currentTime)}
252
260
  hasCustomContent={!!props.renderClipContent}
253
261
  capabilities={getTimelineEditCapabilities(activeDraggedElement)}
254
262
  theme={props.theme}
@@ -12,6 +12,7 @@ interface TimelineClipProps {
12
12
  isSelected: boolean;
13
13
  isHovered: boolean;
14
14
  isDragging?: boolean;
15
+ isActive?: boolean;
15
16
  hasCustomContent: boolean;
16
17
  capabilities: TimelineEditCapabilities;
17
18
  theme?: TimelineTheme;
@@ -35,6 +36,7 @@ export const TimelineClip = memo(function TimelineClip({
35
36
  isSelected,
36
37
  isHovered,
37
38
  isDragging = false,
39
+ isActive = false,
38
40
  hasCustomContent,
39
41
  capabilities,
40
42
  theme = defaultTimelineTheme,
@@ -88,6 +90,8 @@ export const TimelineClip = memo(function TimelineClip({
88
90
  data-clip-start={el.start}
89
91
  data-clip-end={el.start + el.duration}
90
92
  data-clip-hidden={el.hidden ? "true" : undefined}
93
+ data-active={isActive ? "" : undefined}
94
+ tabIndex={-1}
91
95
  className={clipClassName}
92
96
  style={style}
93
97
  title={
@@ -33,6 +33,7 @@ function renderDiamonds(onClickKeyframe = vi.fn()) {
33
33
  }}
34
34
  clipWidthPx={200}
35
35
  clipHeightPx={48}
36
+ clipDuration={10}
36
37
  accentColor="#4ba3d2"
37
38
  isSelected
38
39
  currentPercentage={0}
@@ -46,10 +47,85 @@ function renderDiamonds(onClickKeyframe = vi.fn()) {
46
47
  }
47
48
 
48
49
  describe("TimelineClipDiamonds", () => {
49
- // Dense rows narrow the DIAMOND so neighbours stay individually readable, but
50
- // the hit box floors at KF_MIN_HIT_W — a gap-sized target gets unusable
51
- // (~7px) at the zoom floor.
52
- it("narrows dense keyframe visuals while flooring their hit regions", () => {
50
+ it("marks only the nearest keyframe in a dense lane as under the playhead", () => {
51
+ const host = document.createElement("div");
52
+ document.body.append(host);
53
+ const root = createRoot(host);
54
+ act(() => {
55
+ root.render(
56
+ <TimelineDiamondLane
57
+ keyframesData={{
58
+ format: "percentage",
59
+ keyframes: [34.6, 34.8, 35, 35.2, 35.4].map((percentage) => ({
60
+ percentage,
61
+ propertyGroup: "position",
62
+ properties: { x: percentage },
63
+ })),
64
+ }}
65
+ clipWidthPx={4000}
66
+ clipHeightPx={48}
67
+ clipDuration={12}
68
+ accentColor="#4ba3d2"
69
+ isSelected
70
+ currentPercentage={35.05}
71
+ elementId="clip-1"
72
+ selectedKeyframes={new Set()}
73
+ groupAware
74
+ />,
75
+ );
76
+ });
77
+
78
+ expect(host.querySelectorAll('[data-keyframe-at-playhead="true"]')).toHaveLength(1);
79
+ expect(host.querySelector<HTMLButtonElement>('[data-keyframe-at-playhead="true"]')?.title).toBe(
80
+ "35%",
81
+ );
82
+ act(() => root.unmount());
83
+ });
84
+
85
+ it("distinguishes a playhead match from an explicitly selected keyframe", () => {
86
+ const selectedKey = timelineKeyframeSelectionKey("clip-1", {
87
+ percentage: 60,
88
+ propertyGroup: "position",
89
+ });
90
+ const host = document.createElement("div");
91
+ document.body.append(host);
92
+ const root = createRoot(host);
93
+ act(() => {
94
+ root.render(
95
+ <TimelineDiamondLane
96
+ keyframesData={{
97
+ format: "percentage",
98
+ keyframes: [
99
+ { percentage: 40, propertyGroup: "position", properties: { x: 40 } },
100
+ { percentage: 60, propertyGroup: "position", properties: { x: 60 } },
101
+ ],
102
+ }}
103
+ clipWidthPx={1200}
104
+ clipHeightPx={48}
105
+ clipDuration={10}
106
+ accentColor="#4ba3d2"
107
+ isSelected
108
+ currentPercentage={40}
109
+ elementId="clip-1"
110
+ selectedKeyframes={new Set([selectedKey])}
111
+ groupAware
112
+ />,
113
+ );
114
+ });
115
+
116
+ const playheadDiamond = host.querySelector<HTMLButtonElement>('button[title="40%"]');
117
+ const selectedDiamond = host.querySelector<HTMLButtonElement>('button[title="60%"]');
118
+ expect(playheadDiamond?.dataset.keyframeAtPlayhead).toBe("true");
119
+ expect(playheadDiamond?.dataset.keyframeSelected).toBe("false");
120
+ expect(playheadDiamond?.querySelector("path:last-child")?.getAttribute("fill")).toBe("#a3a3a3");
121
+ expect(playheadDiamond?.querySelector('path[stroke="#4ba3d2"]')).not.toBeNull();
122
+ expect(selectedDiamond?.dataset.keyframeAtPlayhead).toBe("false");
123
+ expect(selectedDiamond?.dataset.keyframeSelected).toBe("true");
124
+ expect(selectedDiamond?.querySelector("path:last-child")?.getAttribute("fill")).toBe("#4ba3d2");
125
+ act(() => root.unmount());
126
+ });
127
+
128
+ it("keeps dense keyframe visuals full-size while bounding their hit regions", () => {
53
129
  const host = document.createElement("div");
54
130
  document.body.append(host);
55
131
  const root = createRoot(host);
@@ -80,7 +156,7 @@ describe("TimelineClipDiamonds", () => {
80
156
  expect(diamonds).toHaveLength(3);
81
157
  for (const diamond of diamonds) {
82
158
  expect(Number.parseFloat(diamond.style.width)).toBeCloseTo(12);
83
- expect(Number(diamond.querySelector("svg")?.getAttribute("width"))).toBeCloseTo(8.8);
159
+ expect(Number(diamond.querySelector("svg")?.getAttribute("width"))).toBe(22);
84
160
  }
85
161
  act(() => root.unmount());
86
162
  });
@@ -123,6 +199,7 @@ describe("TimelineClipDiamonds", () => {
123
199
  keyframesData={{ format: "percentage", keyframes: [groupedKeyframe] }}
124
200
  clipWidthPx={200}
125
201
  clipHeightPx={48}
202
+ clipDuration={10}
126
203
  accentColor="#4ba3d2"
127
204
  isSelected
128
205
  currentPercentage={-10}
@@ -271,6 +348,7 @@ describe("TimelineClipDiamonds", () => {
271
348
  }}
272
349
  clipWidthPx={200}
273
350
  clipHeightPx={48}
351
+ clipDuration={10}
274
352
  accentColor="#4ba3d2"
275
353
  isSelected
276
354
  currentPercentage={0}
@@ -356,6 +434,7 @@ describe("TimelineClipDiamonds", () => {
356
434
  }}
357
435
  clipWidthPx={200}
358
436
  clipHeightPx={48}
437
+ clipDuration={10}
359
438
  accentColor="#4ba3d2"
360
439
  isSelected
361
440
  currentPercentage={0}
@@ -620,6 +699,7 @@ describe("TimelineClipDiamonds", () => {
620
699
  }}
621
700
  clipWidthPx={200}
622
701
  clipHeightPx={48}
702
+ clipDuration={10}
623
703
  accentColor="#4ba3d2"
624
704
  isSelected
625
705
  currentPercentage={0}