@hyperframes/studio 0.8.139 → 0.8.140

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 (72) hide show
  1. package/dist/assets/{hyperframes-player-CckOoCJ8.js → hyperframes-player-BLuyxnK7.js} +1 -1
  2. package/dist/assets/{index-DsEkE5_G.js → index-CWfuDCoA.js} +1 -1
  3. package/dist/assets/{index-BmIPrb8I.js → index-Dr3Bo9uH.js} +1 -1
  4. package/dist/assets/{index-Br1Whc0f.js → index-VAHDM_zL.js} +204 -204
  5. package/dist/{chunk-E422JDF5.js → chunk-H2DUOXTF.js} +2 -1
  6. package/dist/index.d.ts +19 -3
  7. package/dist/index.html +1 -1
  8. package/dist/index.js +6498 -5813
  9. package/dist/index.js.map +1 -1
  10. package/dist/player.js +1 -1
  11. package/package.json +8 -8
  12. package/src/components/TimelineToolbar.test.tsx +79 -1
  13. package/src/components/TimelineToolbar.tsx +14 -29
  14. package/src/components/editor/DomEditOverlay.tsx +2 -2
  15. package/src/components/editor/PropertyPanel.tsx +1 -0
  16. package/src/components/editor/PropertyPanelFlat.tsx +1 -0
  17. package/src/components/editor/manualEdits.ts +1 -1
  18. package/src/components/editor/manualEditsParsing.ts +14 -2
  19. package/src/components/editor/previewReadOnly.overlay.test.tsx +3 -2
  20. package/src/components/editor/propertyPanelFill.tsx +36 -13
  21. package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +4 -0
  22. package/src/components/editor/propertyPanelFlatStyleSections.tsx +13 -7
  23. package/src/components/editor/propertyPanelImageFill.test.tsx +164 -0
  24. package/src/components/editor/propertyPanelStyleSections.tsx +10 -7
  25. package/src/components/nle/NLEPreview.test.ts +44 -1
  26. package/src/components/nle/NLEPreview.tsx +7 -2
  27. package/src/hooks/externalFileChangeOwnership.test.ts +25 -4
  28. package/src/hooks/gsapValueAtPlayhead.browser.test.ts +3 -22
  29. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +536 -10
  30. package/src/hooks/useExternalFileChangeCoordinator.ts +72 -45
  31. package/src/hooks/useMusicBeatAnalysis.ts +2 -1
  32. package/src/hooks/useSdkSession.lifecycle.test.tsx +20 -0
  33. package/src/hooks/useSdkSession.ts +4 -1
  34. package/src/hooks/useThumbnailStripSize.test.tsx +65 -0
  35. package/src/hooks/useThumbnailStripSize.ts +23 -1
  36. package/src/index.ts +1 -0
  37. package/src/player/components/AudioWaveform.test.tsx +153 -1
  38. package/src/player/components/AudioWaveform.tsx +93 -37
  39. package/src/player/components/Timeline.scrollRest.test.tsx +22 -0
  40. package/src/player/components/Timeline.test.ts +3 -1
  41. package/src/player/components/TimelineCanvas.tsx +4 -0
  42. package/src/player/components/TimelineClip.tsx +20 -6
  43. package/src/player/components/TimelineClipFades.test.tsx +11 -0
  44. package/src/player/components/TimelineClipFades.tsx +4 -4
  45. package/src/player/components/TimelineClipJoins.tsx +23 -18
  46. package/src/player/components/TimelineGestureOverlay.tsx +30 -27
  47. package/src/player/components/TimelineGroupRow.tsx +1 -0
  48. package/src/player/components/TimelineLaneClip.tsx +127 -0
  49. package/src/player/components/TimelineLanes.test.tsx +51 -6
  50. package/src/player/components/TimelineLanes.tsx +166 -178
  51. package/src/player/components/TimelineRuler.tsx +2 -0
  52. package/src/player/components/TimelineTimeLayer.tsx +25 -0
  53. package/src/player/components/VideoThumbnail.test.tsx +204 -12
  54. package/src/player/components/VideoThumbnail.tsx +37 -9
  55. package/src/player/components/thumbnailUtils.test.ts +17 -3
  56. package/src/player/components/thumbnailUtils.ts +17 -1
  57. package/src/player/components/timelineLayout.test.ts +3 -3
  58. package/src/player/components/timelineMotion.test.ts +27 -0
  59. package/src/player/components/timelineMotion.ts +62 -0
  60. package/src/player/components/timelineZoomInput.test.ts +438 -0
  61. package/src/player/components/timelineZoomInput.ts +456 -0
  62. package/src/player/components/useTimelineGeometry.ts +0 -3
  63. package/src/player/components/useTimelinePlayhead.test.tsx +97 -30
  64. package/src/player/components/useTimelinePlayhead.ts +53 -59
  65. package/src/player/components/useTimelineProviderState.tsx +16 -21
  66. package/src/player/components/useTimelineZoom.ts +7 -12
  67. package/src/player/lib/thumbnailVideoDecoder.test.ts +230 -30
  68. package/src/player/lib/thumbnailVideoDecoder.ts +197 -54
  69. package/src/player/lib/timelineViewportBudgets.test.ts +1 -1
  70. package/src/player/lib/timelineViewportBudgets.ts +1 -1
  71. package/src/utils/gsapUndoRestore.browser.test.ts +4 -17
  72. /package/dist/{chunk-E422JDF5.js.map → chunk-H2DUOXTF.js.map} +0 -0
@@ -1,6 +1,6 @@
1
1
  import { useCallback, useEffect, useRef, useState, type MutableRefObject } from "react";
2
2
  import {
3
- mergeFileChangeAffectedCompositions,
3
+ mergeFileChangePayloads,
4
4
  readFileChangeAffectedCompositions,
5
5
  readFileChangeAffectsPreview,
6
6
  readFileChangeField,
@@ -125,14 +125,18 @@ function decodeFileChange(delivery: unknown): unknown {
125
125
  }
126
126
  }
127
127
 
128
- /**
129
- * The production transport. vitest defines `import.meta.hot`, so the selection
130
- * below never reaches this rung under test; the rung itself is exported so a test
131
- * can drive a real `MessageEvent` through the listener it registers.
132
- */
133
- export function sseFileChangeChannel(onDelivery: (delivery: unknown) => void): () => void {
128
+ export function sseFileChangeChannel(
129
+ onDelivery: (delivery: unknown) => void,
130
+ onReconnect: () => void,
131
+ ): () => void {
134
132
  const eventSource = new EventSource("/api/events");
135
133
  eventSource.addEventListener("file-change", onDelivery);
134
+ // Reopening the stream does not replay changes missed during a disconnect.
135
+ let opened = false;
136
+ eventSource.addEventListener("open", () => {
137
+ if (opened) onReconnect();
138
+ opened = true;
139
+ });
136
140
  return () => eventSource.close();
137
141
  }
138
142
 
@@ -168,18 +172,19 @@ export function useExternalFileChangeCoordinator({
168
172
  const generationRef = useRef(0);
169
173
  const mountedRef = useRef(true);
170
174
  const lastEventIdentityRef = useRef<string | null>(null);
171
- const blockedRef = useRef(blocked);
175
+ const blockedRef = useRef<ExternalFileChangeBlockedState | null>(null);
172
176
  const snapshotWriteTailRef = useRef<Promise<void>>(Promise.resolve());
173
177
  const drainingRef = useRef(false);
174
178
  const pendingPayloadRef = useRef<{ payload: unknown } | null>(null);
175
- blockedRef.current = blocked;
176
- // A newer blocked change replaces the held one, so it inherits the thumbnails the held one owed.
179
+ // A newer blocked change replaces the held one, so it inherits what the held one owed.
177
180
  const setBlocked = useCallback((next: ExternalFileChangeBlockedState | null) => {
178
- setBlockedState((held) =>
181
+ const held = blockedRef.current;
182
+ const current =
179
183
  next && held
180
- ? { ...next, payload: mergeFileChangeAffectedCompositions(held.payload, next.payload) }
181
- : next,
182
- );
184
+ ? { ...next, payload: mergeFileChangePayloads(held.payload, next.payload) }
185
+ : next;
186
+ blockedRef.current = current;
187
+ setBlockedState(current);
183
188
  }, []);
184
189
 
185
190
  useEffect(() => {
@@ -190,6 +195,10 @@ export function useExternalFileChangeCoordinator({
190
195
  };
191
196
  }, []);
192
197
 
198
+ useEffect(() => {
199
+ pendingPayloadRef.current = null;
200
+ }, [projectId]);
201
+
193
202
  useEffect(() => {
194
203
  generationRef.current += 1;
195
204
  setBlocked(null);
@@ -240,8 +249,12 @@ export function useExternalFileChangeCoordinator({
240
249
  };
241
250
  }, [loadConflictSnapshot, projectId, recoveryFilePath, setBlocked]);
242
251
 
243
- const reloadAcceptedGeneration = useCallback(
244
- (path: string, affectsPreview = true) => {
252
+ // What a settled change owes: thumbnails (unless its own write's echo brings them), then reloads.
253
+ const deliverOwed = useCallback(
254
+ (owed: unknown, accept = true) => {
255
+ const path = readStudioFileChangePath(owed) ?? ".";
256
+ const affectsPreview = readFileChangeAffectsPreview(owed);
257
+ if (accept) onAcceptedPersistedFileChange(path, readFileChangeAffectedCompositions(owed));
245
258
  logReload(affectsPreview ? "reload" : "file-tree only", {
246
259
  path,
247
260
  by: "external-change coordinator",
@@ -255,7 +268,7 @@ export function useExternalFileChangeCoordinator({
255
268
  // a watcher event the SSE never delivered).
256
269
  void refreshFileTree?.();
257
270
  },
258
- [reloadPreview, reloadSdkSession, refreshFileTree],
271
+ [onAcceptedPersistedFileChange, reloadPreview, reloadSdkSession, refreshFileTree],
259
272
  );
260
273
 
261
274
  const persistSnapshotInOrder = useCallback(async (write: () => Promise<void>) => {
@@ -274,21 +287,28 @@ export function useExternalFileChangeCoordinator({
274
287
  if (!path) return;
275
288
 
276
289
  const generation = ++generationRef.current;
290
+ const savingPath = getPendingCandidate?.()?.path;
277
291
  const result = await drainPendingChanges();
278
292
  if (!mountedRef.current || generation !== generationRef.current) return;
279
293
 
280
294
  if (result.status === "clean") {
281
295
  const previousBlocked = blockedRef.current;
282
- if (previousBlocked?.status === "failed" && deleteConflictSnapshot) {
296
+ // Only saving the failed draft's file retires its copy; restored drafts are never queued.
297
+ if (
298
+ previousBlocked?.status === "failed" &&
299
+ !previousBlocked.recovered &&
300
+ previousBlocked.path === savingPath &&
301
+ deleteConflictSnapshot
302
+ ) {
283
303
  try {
284
- await deleteConflictSnapshot(projectId!, path);
304
+ await deleteConflictSnapshot(projectId!, previousBlocked.path);
285
305
  } catch (error) {
286
306
  if (mountedRef.current && generation === generationRef.current) {
287
307
  setBlocked({
288
308
  ...previousBlocked,
289
309
  generation,
290
310
  error,
291
- payload: mergeFileChangeAffectedCompositions(payload, previousBlocked.payload),
311
+ payload: mergeFileChangePayloads(payload, previousBlocked.payload),
292
312
  });
293
313
  }
294
314
  return;
@@ -296,27 +316,30 @@ export function useExternalFileChangeCoordinator({
296
316
  }
297
317
  if (!mountedRef.current || generation !== generationRef.current) return;
298
318
  setBlocked(null);
299
- const owed = previousBlocked
300
- ? mergeFileChangeAffectedCompositions(previousBlocked.payload, payload)
301
- : payload;
302
- onAcceptedPersistedFileChange(path, readFileChangeAffectedCompositions(owed));
303
- reloadAcceptedGeneration(path, readFileChangeAffectsPreview(payload));
319
+ const restored = previousBlocked?.status === "failed" && previousBlocked.recovered;
320
+ deliverOwed(
321
+ previousBlocked && !restored
322
+ ? mergeFileChangePayloads(previousBlocked.payload, payload)
323
+ : payload,
324
+ );
304
325
  return;
305
326
  }
306
- const content = readFileChangeContent(payload);
307
327
  if (result.status === "failed") {
328
+ // The draft names the file to recover; the payload stays the scope to reload.
308
329
  const candidate = getPendingCandidate?.();
309
- const studioContent = candidate?.path === path ? candidate.content : null;
330
+ const failedPath = candidate?.path ?? (path === "." ? (recoveryFilePath ?? path) : path);
331
+ const studioContent = candidate?.content ?? null;
332
+ const external = failedPath === path ? payload : null;
310
333
  let error = result.error;
311
334
  if (studioContent != null && persistFailureSnapshot) {
312
335
  try {
313
336
  await persistSnapshotInOrder(() =>
314
337
  persistFailureSnapshot(
315
338
  projectId!,
316
- path,
339
+ failedPath,
317
340
  studioContent,
318
- readFileChangeVersion(payload),
319
- content,
341
+ readFileChangeVersion(external),
342
+ readFileChangeContent(external),
320
343
  result.error,
321
344
  ),
322
345
  );
@@ -333,7 +356,7 @@ export function useExternalFileChangeCoordinator({
333
356
  setBlocked({
334
357
  status: "failed",
335
358
  generation,
336
- path,
359
+ path: failedPath,
337
360
  error,
338
361
  payload,
339
362
  studioContent,
@@ -348,7 +371,7 @@ export function useExternalFileChangeCoordinator({
348
371
  setBlocked({
349
372
  status: "failed",
350
373
  generation,
351
- path,
374
+ path: result.error.filePath,
352
375
  error,
353
376
  payload,
354
377
  studioContent: result.error.attemptedContent,
@@ -365,14 +388,17 @@ export function useExternalFileChangeCoordinator({
365
388
  projectId,
366
389
  deleteConflictSnapshot,
367
390
  getPendingCandidate,
391
+ recoveryFilePath,
368
392
  persistConflictSnapshot,
369
393
  persistFailureSnapshot,
370
394
  persistSnapshotInOrder,
371
- reloadAcceptedGeneration,
372
- onAcceptedPersistedFileChange,
395
+ deliverOwed,
373
396
  ],
374
397
  );
375
398
 
399
+ const drainOnePendingRef = useRef(drainOnePending);
400
+ drainOnePendingRef.current = drainOnePending;
401
+
376
402
  const startDrainLoop = useCallback(async () => {
377
403
  if (drainingRef.current) return;
378
404
  drainingRef.current = true;
@@ -381,12 +407,12 @@ export function useExternalFileChangeCoordinator({
381
407
  const pending = pendingPayloadRef.current;
382
408
  if (!pending) break;
383
409
  pendingPayloadRef.current = null;
384
- await drainOnePending(pending.payload);
410
+ await drainOnePendingRef.current(pending.payload);
385
411
  }
386
412
  } finally {
387
413
  drainingRef.current = false;
388
414
  }
389
- }, [drainOnePending]);
415
+ }, []);
390
416
 
391
417
  const processChange = useCallback(
392
418
  // fallow-ignore-next-line complexity
@@ -439,8 +465,7 @@ export function useExternalFileChangeCoordinator({
439
465
  !readFileChangeAffectsPreview(payload);
440
466
  if (!waitingChangeOutranksThis) {
441
467
  pendingPayloadRef.current = {
442
- payload:
443
- waiting == null ? payload : mergeFileChangeAffectedCompositions(waiting, payload),
468
+ payload: waiting == null ? payload : mergeFileChangePayloads(waiting, payload),
444
469
  };
445
470
  }
446
471
  void startDrainLoop();
@@ -462,7 +487,8 @@ export function useExternalFileChangeCoordinator({
462
487
  import.meta.hot.on("hf:file-change", handler);
463
488
  return () => import.meta.hot?.off?.("hf:file-change", handler);
464
489
  }
465
- return sseFileChangeChannel(handler);
490
+ const catchUp = () => processChangeRef.current({ path: "." });
491
+ return sseFileChangeChannel(handler, catchUp);
466
492
  }, []);
467
493
 
468
494
  const retry = useCallback(async () => {
@@ -489,18 +515,16 @@ export function useExternalFileChangeCoordinator({
489
515
  onUseExternalFile?.(path, external);
490
516
  await deleteConflictSnapshot?.(projectId, path);
491
517
  setBlocked(null);
492
- onAcceptedPersistedFileChange(path, readFileChangeAffectedCompositions(current.payload));
493
- reloadAcceptedGeneration(path, readFileChangeAffectsPreview(current.payload));
518
+ deliverOwed(current.payload);
494
519
  },
495
520
  [
496
521
  deleteConflictSnapshot,
497
522
  setBlocked,
498
523
  discardPendingChanges,
499
524
  onUseExternalFile,
500
- onAcceptedPersistedFileChange,
501
525
  projectId,
502
526
  readProjectFile,
503
- reloadAcceptedGeneration,
527
+ deliverOwed,
504
528
  resetSaveQueues,
505
529
  ],
506
530
  );
@@ -550,7 +574,10 @@ export function useExternalFileChangeCoordinator({
550
574
  resetSaveQueues?.();
551
575
  await deleteConflictSnapshot?.(projectId, conflict.filePath);
552
576
  setBlocked(null);
553
- reloadAcceptedGeneration(conflict.filePath);
577
+ deliverOwed(
578
+ mergeFileChangePayloads(current.payload, { path: conflict.filePath, affectsPreview: true }),
579
+ readStudioFileChangePath(current.payload) !== conflict.filePath,
580
+ );
554
581
  }, [
555
582
  deleteConflictSnapshot,
556
583
  setBlocked,
@@ -558,7 +585,7 @@ export function useExternalFileChangeCoordinator({
558
585
  overwriteConflict,
559
586
  projectId,
560
587
  readProjectFile,
561
- reloadAcceptedGeneration,
588
+ deliverOwed,
562
589
  resetSaveQueues,
563
590
  ]);
564
591
 
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef } from "react";
2
2
  import { usePlayerStore } from "../player/store/playerStore";
3
3
  import { resolveBeatSourceTrack } from "../utils/timelineInspector";
4
4
  import { analyzeMusicFromUrl } from "@hyperframes/core/beats";
5
+ import { whenTimelineIdle } from "../player/components/timelineMotion";
5
6
  import { useFileManagerContextOptional } from "../contexts/FileManagerContext";
6
7
  import { mergeUserBeats } from "../utils/beatEditing";
7
8
  import { getTimelineElementIndexes } from "../player/lib/timelineElementIndexes";
@@ -79,7 +80,7 @@ async function loadBeatAnalysis(
79
80
  ): Promise<{ analysis: MusicAnalysis; times: number[]; strengths: number[] } | null> {
80
81
  let promise = analysisCache.get(musicSrc);
81
82
  if (!promise) {
82
- promise = analyzeMusicFromUrl(musicSrc);
83
+ promise = analyzeMusicFromUrl(musicSrc, { pause: whenTimelineIdle });
83
84
  cacheAnalysis(musicSrc, promise);
84
85
  }
85
86
  try {
@@ -13,6 +13,7 @@ vi.mock("@hyperframes/sdk", () => ({
13
13
  import type { Composition } from "@hyperframes/sdk";
14
14
  import { useSdkSession, type SdkSessionHandle } from "./useSdkSession";
15
15
  import { usePlayerStore } from "../player/store/playerStore";
16
+ import { notifyExternalFileReload } from "./externalFileReloadBus";
16
17
 
17
18
  vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
18
19
 
@@ -173,6 +174,25 @@ describe("useSdkSession ownership", () => {
173
174
  await act(async () => root.unmount());
174
175
  expect(published.dispose).toHaveBeenCalledOnce();
175
176
  });
177
+
178
+ it("reopens on a project-wide external change whatever composition it holds", async () => {
179
+ vi.stubGlobal(
180
+ "fetch",
181
+ vi.fn(async () => response("INDEX")),
182
+ );
183
+ openComposition.mockImplementation(async () => fakeSession());
184
+ const root = createRoot(document.createElement("div"));
185
+ await act(async () => root.render(<Probe projectId="project-a" />));
186
+ await flushAsyncEffects();
187
+ expect(openComposition).toHaveBeenCalledOnce();
188
+ await act(async () => notifyExternalFileReload("film.html"));
189
+ await flushAsyncEffects();
190
+ expect(openComposition).toHaveBeenCalledOnce();
191
+ await act(async () => notifyExternalFileReload("."));
192
+ await flushAsyncEffects();
193
+ expect(openComposition).toHaveBeenCalledTimes(2);
194
+ act(() => root.unmount());
195
+ });
176
196
  });
177
197
 
178
198
  describe("useSdkSession unreachable project", () => {
@@ -409,7 +409,10 @@ export function useSdkSession(
409
409
  useEffect(
410
410
  () =>
411
411
  addExternalFileReloadListener((changedPath) => {
412
- if (changedPath === activeCompPathRef.current) setReloadToken((token) => token + 1);
412
+ // "." is a project-wide change (a reconnect or a replaced folder): every session reloads.
413
+ if (changedPath === "." || changedPath === activeCompPathRef.current) {
414
+ setReloadToken((token) => token + 1);
415
+ }
413
416
  }),
414
417
  [],
415
418
  );
@@ -7,6 +7,15 @@ import { NearScreenIntersectionObserver } from "./intersectionObserverTestUtils"
7
7
  import { MockResizeObserver, reportResize } from "./resizeObserverTestUtils";
8
8
  import { useThumbnailStripSize } from "./useThumbnailStripSize";
9
9
 
10
+ const zoom = vi.hoisted(() => ({ previewing: false, listeners: new Set<() => void>() }));
11
+ vi.mock("../player/components/timelineZoomInput", () => ({
12
+ isTimelineZoomPreviewing: () => zoom.previewing,
13
+ subscribeTimelineZoomPreview: (listener: () => void) => {
14
+ zoom.listeners.add(listener);
15
+ return () => zoom.listeners.delete(listener);
16
+ },
17
+ }));
18
+
10
19
  Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
11
20
 
12
21
  it("does not re-render the strip when the observer reports the size it already holds", () => {
@@ -48,6 +57,62 @@ it("does not re-render the strip when the observer reports the size it already h
48
57
  }
49
58
  });
50
59
 
60
+ it("measures a strip once a zoom preview ends, not while the preview scales it", () => {
61
+ const originalResizeObserver = globalThis.ResizeObserver;
62
+ globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
63
+ const host = document.createElement("div");
64
+ document.body.append(host);
65
+ let width = 300;
66
+ Object.defineProperty(host, "clientWidth", { configurable: true, get: () => width });
67
+ Object.defineProperty(host, "clientHeight", { configurable: true, value: 40 });
68
+ const root = createRoot(host);
69
+ function Harness() {
70
+ const [size, ref] = useThumbnailStripSize();
71
+ return <div ref={ref}>{`${size.width}x${size.height}`}</div>;
72
+ }
73
+ try {
74
+ act(() => root.render(<Harness />));
75
+ zoom.previewing = true;
76
+ width = 600;
77
+ act(() => reportResize(600, 40));
78
+ expect(host.textContent).toBe("300x40");
79
+ zoom.previewing = false;
80
+ act(() => zoom.listeners.forEach((listener) => listener()));
81
+ expect(host.textContent).toBe("600x40");
82
+ } finally {
83
+ zoom.previewing = false;
84
+ act(() => root.unmount());
85
+ host.remove();
86
+ globalThis.ResizeObserver = originalResizeObserver;
87
+ }
88
+ });
89
+
90
+ it("measures again each time a preview is laid out, though nothing was read during it", () => {
91
+ const originalResizeObserver = globalThis.ResizeObserver;
92
+ globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
93
+ const host = document.createElement("div");
94
+ document.body.append(host);
95
+ let width = 300;
96
+ Object.defineProperty(host, "clientWidth", { configurable: true, get: () => width });
97
+ Object.defineProperty(host, "clientHeight", { configurable: true, value: 40 });
98
+ const root = createRoot(host);
99
+ function Harness() {
100
+ const [size, ref] = useThumbnailStripSize();
101
+ return <div ref={ref}>{`${size.width}x${size.height}`}</div>;
102
+ }
103
+ try {
104
+ act(() => root.render(<Harness />));
105
+ // A zoom-out lays its target out mid-ease; the resize is reported only later.
106
+ width = 600;
107
+ act(() => zoom.listeners.forEach((listener) => listener()));
108
+ expect(host.textContent).toBe("600x40");
109
+ } finally {
110
+ act(() => root.unmount());
111
+ host.remove();
112
+ globalThis.ResizeObserver = originalResizeObserver;
113
+ }
114
+ });
115
+
51
116
  describe("on a scroll", () => {
52
117
  const originalResizeObserver = globalThis.ResizeObserver;
53
118
  const originalIntersectionObserver = globalThis.IntersectionObserver;
@@ -1,5 +1,9 @@
1
1
  import { useCallback, useState } from "react";
2
2
  import { flushSync } from "react-dom";
3
+ import {
4
+ isTimelineZoomPreviewing,
5
+ subscribeTimelineZoomPreview,
6
+ } from "../player/components/timelineZoomInput";
3
7
 
4
8
  export interface StripSize {
5
9
  width: number;
@@ -24,7 +28,6 @@ const GAP_WARNING: IntersectionObserverInit & { scrollMargin: string } = {
24
28
  scrollMargin: `0px ${IN_VIEW_CHUNK_PX / 2}px`,
25
29
  };
26
30
 
27
- // A short strip keeps every tile, as before, so only long clips do work on a scroll.
28
31
  const SHORT_STRIP_MAX_PX = 8 * IN_VIEW_CHUNK_PX;
29
32
  const isShort = (width: number) => width <= SHORT_STRIP_MAX_PX;
30
33
 
@@ -62,6 +65,7 @@ let shared: {
62
65
  resize: ResizeObserver;
63
66
  presence: IntersectionObserver | null;
64
67
  gaps: IntersectionObserver | null;
68
+ stopZoomWatch: () => void;
65
69
  } | null = null;
66
70
 
67
71
  const offsetOf = (scroller: Element | null) =>
@@ -97,8 +101,23 @@ const read = (target: Element, strip: Strip) => {
97
101
  const commit = (updates: (readonly [Apply, Partial<StripSize>])[]) =>
98
102
  flushSync(() => updates.forEach(([apply, patch]) => apply(patch)));
99
103
 
104
+ // A zoom preview scales the strips: reads wait, and each preview's layout re-measures them all.
105
+ const remeasureAfterPreview = () => {
106
+ if (isTimelineZoomPreviewing()) return;
107
+ commit(
108
+ [...strips].map(
109
+ ([target, strip]) =>
110
+ [
111
+ strip.apply,
112
+ { width: target.clientWidth, height: target.clientHeight, ...read(target, strip) },
113
+ ] as const,
114
+ ),
115
+ );
116
+ };
117
+
100
118
  const refresh = () => {
101
119
  frame = 0;
120
+ if (isTimelineZoomPreviewing()) return;
102
121
  const offsetsNow = new Map<Element | null, { x: number; y: number }>();
103
122
  const updates: (readonly [Apply, Partial<StripSize>])[] = [];
104
123
  for (const [target, strip] of strips) {
@@ -122,6 +141,7 @@ const scheduleRefresh = () => {
122
141
  };
123
142
 
124
143
  const measure = (entries: { target: Element; size?: { width: number; height: number } }[]) =>
144
+ isTimelineZoomPreviewing() ||
125
145
  commit(
126
146
  entries.flatMap(({ target, size }) => {
127
147
  const strip = strips.get(target);
@@ -153,6 +173,7 @@ function acquire() {
153
173
  gaps: observeIntersections(
154
174
  (entries) => entries.some((entry) => entry.isIntersecting) && scheduleRefresh(),
155
175
  ),
176
+ stopZoomWatch: subscribeTimelineZoomPreview(remeasureAfterPreview),
156
177
  };
157
178
  window.addEventListener("scroll", scheduleRefresh, { capture: true, passive: true });
158
179
  }
@@ -164,6 +185,7 @@ function release() {
164
185
  shared?.resize.disconnect();
165
186
  shared?.presence?.disconnect();
166
187
  shared?.gaps?.disconnect();
188
+ shared?.stopZoomWatch();
167
189
  shared = null;
168
190
  window.removeEventListener("scroll", scheduleRefresh, { capture: true });
169
191
  cancelAnimationFrame(frame);
package/src/index.ts CHANGED
@@ -63,6 +63,7 @@ export {
63
63
  export { TimelineProvider, useTimelineContext } from "./player/components/TimelineProvider";
64
64
  export type { TimelineTheme } from "./player/components/timelineTheme";
65
65
  export { TRACK_H } from "./player/components/timelineLayout";
66
+ export { currentTimelineRange, zoomTimelineToRange } from "./player/components/timelineZoomInput";
66
67
  export type { TimelineTrackPadding } from "./player/components/timelineLayout";
67
68
  export { displayTrackOrder } from "./player/components/useTimelineTrackDerivations";
68
69
  export { fadeHandleBoxes } from "./player/components/timelineClipFadeGeometry";
@@ -11,15 +11,98 @@ vi.mock("../../hooks/useThumbnailLease", () => ({
11
11
  useThumbnailLease: leaseSpy,
12
12
  }));
13
13
 
14
+ // One observer serves every waveform; it reports each one near as it is watched, unless a test
15
+ // reports by hand.
16
+ const nearScreen = {
17
+ auto: true,
18
+ watched: [] as Element[],
19
+ report: (_target: Element, _near: boolean) => {},
20
+ };
21
+ globalThis.IntersectionObserver = class {
22
+ constructor(callback: IntersectionObserverCallback) {
23
+ nearScreen.report = (target, near) =>
24
+ callback(
25
+ [{ target, isIntersecting: near } as IntersectionObserverEntry],
26
+ this as unknown as IntersectionObserver,
27
+ );
28
+ }
29
+ observe(target: Element) {
30
+ nearScreen.watched.push(target);
31
+ if (nearScreen.auto) nearScreen.report(target, true);
32
+ }
33
+ unobserve() {}
34
+ disconnect() {}
35
+ } as unknown as typeof IntersectionObserver;
36
+
37
+ const EMPTY_STRIP = { width: 0, height: 0, inViewStart: 0, inViewEnd: 0 };
38
+ const watchGap = vi.hoisted(() => vi.fn());
39
+ const strip = vi.hoisted(() => ({
40
+ size: { width: 0, height: 0, inViewStart: 0, inViewEnd: 0 },
41
+ listeners: new Set<() => void>(),
42
+ }));
43
+ // The strip hook's own tests cover scrolls and moves; here it reports what the test sets.
44
+ vi.mock("../../hooks/useThumbnailStripSize", async () => {
45
+ const { useSyncExternalStore } = await import("react");
46
+ const subscribe = (listener: () => void) => {
47
+ strip.listeners.add(listener);
48
+ return () => strip.listeners.delete(listener);
49
+ };
50
+ return {
51
+ useThumbnailStripSize: () => [
52
+ useSyncExternalStore(subscribe, () => strip.size),
53
+ () => {},
54
+ watchGap,
55
+ ],
56
+ };
57
+ });
58
+
59
+ const setStrip = (size: typeof EMPTY_STRIP) =>
60
+ act(() => {
61
+ strip.size = size;
62
+ strip.listeners.forEach((listener) => listener());
63
+ });
64
+
14
65
  import { AudioWaveform, drawWaveformCanvas } from "./AudioWaveform";
15
66
 
16
67
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
17
68
 
69
+ const mounted: Array<ReturnType<typeof createRoot>> = [];
70
+
18
71
  afterEach(() => {
19
- leaseSpy.mockClear();
72
+ act(() => mounted.splice(0).forEach((root) => root.unmount()));
73
+ vi.restoreAllMocks();
74
+ vi.useRealTimers();
75
+ leaseSpy.mockReset();
76
+ leaseSpy.mockImplementation(() => ({ status: "loading" as const }));
77
+ strip.size = EMPTY_STRIP;
78
+ nearScreen.auto = true;
79
+ watchGap.mockClear();
20
80
  document.body.innerHTML = "";
21
81
  });
22
82
 
83
+ /** Renders a ready waveform for a two-peak sound into `parent`. */
84
+ function renderReadyWaveform(parent: HTMLElement = document.body) {
85
+ // One snapshot, as the lease returns, so a re-render alone does not redraw.
86
+ const ready = { status: "ready", value: { kind: "waveform", peaks: [0.5, 1] } } as never;
87
+ leaseSpy.mockImplementation(() => ready);
88
+ const host = document.createElement("div");
89
+ parent.append(host);
90
+ const root = createRoot(host);
91
+ mounted.push(root);
92
+ act(() => {
93
+ root.render(
94
+ <AudioWaveform
95
+ audioUrl="/media/voice.wav"
96
+ label=""
97
+ labelColor="#fff"
98
+ projectId="project-a"
99
+ sessionEpoch={1}
100
+ priority="visible"
101
+ />,
102
+ );
103
+ });
104
+ }
105
+
23
106
  /** A 6 x 20 canvas whose 2D context records every fill as [style, x, y, width, height]. */
24
107
  function recordingCanvas() {
25
108
  const fills: Array<[string, number, number, number, number]> = [];
@@ -51,6 +134,75 @@ describe("AudioWaveform", () => {
51
134
  ]);
52
135
  });
53
136
 
137
+ it("draws only the span of the clip it is given", () => {
138
+ const { canvas, fills } = recordingCanvas();
139
+ // The clip's second half holds the loud peak only.
140
+ drawWaveformCanvas(canvas, [0.25, 1], false, 0, 1, null, { from: 0.5, to: 1 });
141
+ const bars = fills.filter(([, , y, , height]) => !(y === 18 && height === 2));
142
+ expect(bars.map(([, , y, , height]) => [y, height])).toEqual([
143
+ [0, 20],
144
+ [0, 20],
145
+ ]);
146
+ });
147
+
148
+ it("draws only the stretch of a long clip near the screen, and follows it as the clip moves", () => {
149
+ vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
150
+ setStrip({ width: 10_000, height: 40, inViewStart: 2048, inViewEnd: 4096 });
151
+ renderReadyWaveform();
152
+ const canvas = document.querySelector("canvas")!;
153
+ const placed = () => [parseFloat(canvas.style.left), parseFloat(canvas.style.width)];
154
+ expect(placed()[0]).toBeCloseTo(20.48);
155
+ expect(placed()[1]).toBeCloseTo(20.48);
156
+ // A move or scroll carries the clip: the hook reports a new stretch and the bars follow.
157
+ setStrip({ width: 10_000, height: 40, inViewStart: 4096, inViewEnd: 6144 });
158
+ expect(placed()[0]).toBeCloseTo(40.96);
159
+ expect(placed()[1]).toBeCloseTo(20.48);
160
+ });
161
+
162
+ it("watches no ends of a clip drawn whole", () => {
163
+ setStrip({ width: 1000, height: 40, inViewStart: 0, inViewEnd: 1000 });
164
+ renderReadyWaveform();
165
+ expect(watchGap.mock.calls.filter(([gap]) => gap)).toEqual([]);
166
+ });
167
+
168
+ it("leaves a waveform off screen undrawn through a zoom, and draws it once it comes near", () => {
169
+ nearScreen.auto = false;
170
+ const getContext = vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
171
+ setStrip({ width: 1000, height: 40, inViewStart: 0, inViewEnd: 1000 });
172
+ renderReadyWaveform();
173
+ const root = nearScreen.watched.at(-1)!;
174
+ expect(getContext).not.toHaveBeenCalled();
175
+ act(() => nearScreen.report(root, true));
176
+ const drawn = getContext.mock.calls.length;
177
+ expect(drawn).toBeGreaterThan(0);
178
+ act(() => nearScreen.report(root, false));
179
+ setStrip({ width: 2000, height: 40, inViewStart: 0, inViewEnd: 2000 });
180
+ expect(getContext.mock.calls.length).toBe(drawn);
181
+ act(() => nearScreen.report(root, true));
182
+ expect(getContext.mock.calls.length).toBe(drawn + 1);
183
+ });
184
+
185
+ it("redraws a short clip, drawn whole, when a zoom changes its width", () => {
186
+ const getContext = vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
187
+ setStrip({ width: 1000, height: 40, inViewStart: 0, inViewEnd: 1000 });
188
+ renderReadyWaveform();
189
+ const drawn = getContext.mock.calls.length;
190
+ setStrip({ width: 2000, height: 40, inViewStart: 0, inViewEnd: 2000 });
191
+ expect(getContext.mock.calls.length).toBe(drawn + 1);
192
+ });
193
+
194
+ it("watches both undrawn ends of a long clip, so a move without a scroll re-measures it", () => {
195
+ setStrip({ width: 10_000, height: 40, inViewStart: 2048, inViewEnd: 4096 });
196
+ renderReadyWaveform();
197
+ const gaps = [
198
+ ...new Set(watchGap.mock.calls.map(([gap]) => gap).filter(Boolean)),
199
+ ] as HTMLElement[];
200
+ expect(gaps.map((gap) => [gap.style.width, gap.style.left])).toEqual([
201
+ ["20.48%", ""],
202
+ ["", "40.96%"],
203
+ ]);
204
+ });
205
+
54
206
  it("shrinks each bar to the fade's gain and keeps the cut-away part as a ghost", () => {
55
207
  const { canvas, fills } = recordingCanvas();
56
208
  // A 1 s fade-in on a 2 s clip: the first bar's centre (0.5 s) plays at half gain.