@hyperframes/studio 0.8.139 → 0.8.141
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/hyperframes-player-5Vx2AiUz.js +473 -0
- package/dist/assets/{index-DsEkE5_G.js → index-BAnh7cSQ.js} +1 -1
- package/dist/assets/{index-Br1Whc0f.js → index-C0thd4eM.js} +204 -204
- package/dist/assets/{index-BmIPrb8I.js → index-P8fgj_Xa.js} +1 -1
- package/dist/{chunk-E422JDF5.js → chunk-H2DUOXTF.js} +2 -1
- package/dist/index.d.ts +19 -3
- package/dist/index.html +1 -1
- package/dist/index.js +6518 -5813
- package/dist/index.js.map +1 -1
- package/dist/player.js +1 -1
- package/package.json +8 -8
- package/src/components/TimelineToolbar.test.tsx +79 -1
- package/src/components/TimelineToolbar.tsx +14 -29
- package/src/components/editor/DomEditOverlay.tsx +2 -2
- package/src/components/editor/PropertyPanel.tsx +1 -0
- package/src/components/editor/PropertyPanelFlat.tsx +1 -0
- package/src/components/editor/manualEdits.ts +1 -1
- package/src/components/editor/manualEditsParsing.ts +14 -2
- package/src/components/editor/previewReadOnly.overlay.test.tsx +3 -2
- package/src/components/editor/propertyPanelFill.tsx +36 -13
- package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +4 -0
- package/src/components/editor/propertyPanelFlatStyleSections.tsx +13 -7
- package/src/components/editor/propertyPanelImageFill.test.tsx +164 -0
- package/src/components/editor/propertyPanelStyleSections.tsx +10 -7
- package/src/components/nle/NLEPreview.test.ts +60 -1
- package/src/components/nle/NLEPreview.tsx +9 -2
- package/src/components/sidebar/CompositionsTab.thumbnails.test.tsx +43 -0
- package/src/components/sidebar/CompositionsTab.tsx +17 -0
- package/src/hooks/externalFileChangeOwnership.test.ts +25 -4
- package/src/hooks/gsapValueAtPlayhead.browser.test.ts +3 -22
- package/src/hooks/useExternalFileChangeCoordinator.test.tsx +536 -10
- package/src/hooks/useExternalFileChangeCoordinator.ts +72 -45
- package/src/hooks/useMusicBeatAnalysis.ts +2 -1
- package/src/hooks/useSdkSession.lifecycle.test.tsx +20 -0
- package/src/hooks/useSdkSession.ts +4 -1
- package/src/hooks/useThumbnailStripSize.test.tsx +65 -0
- package/src/hooks/useThumbnailStripSize.ts +23 -1
- package/src/index.ts +1 -0
- package/src/player/components/AudioWaveform.test.tsx +153 -1
- package/src/player/components/AudioWaveform.tsx +93 -37
- package/src/player/components/Timeline.scrollRest.test.tsx +22 -0
- package/src/player/components/Timeline.test.ts +3 -1
- package/src/player/components/TimelineCanvas.tsx +4 -0
- package/src/player/components/TimelineClip.tsx +20 -6
- package/src/player/components/TimelineClipFades.test.tsx +11 -0
- package/src/player/components/TimelineClipFades.tsx +4 -4
- package/src/player/components/TimelineClipJoins.tsx +23 -18
- package/src/player/components/TimelineGestureOverlay.tsx +30 -27
- package/src/player/components/TimelineGroupRow.tsx +1 -0
- package/src/player/components/TimelineLaneClip.tsx +127 -0
- package/src/player/components/TimelineLanes.test.tsx +51 -6
- package/src/player/components/TimelineLanes.tsx +166 -178
- package/src/player/components/TimelineRuler.tsx +2 -0
- package/src/player/components/TimelineTimeLayer.tsx +25 -0
- package/src/player/components/VideoThumbnail.test.tsx +204 -12
- package/src/player/components/VideoThumbnail.tsx +37 -9
- package/src/player/components/thumbnailUtils.test.ts +17 -3
- package/src/player/components/thumbnailUtils.ts +17 -1
- package/src/player/components/timelineLayout.test.ts +3 -3
- package/src/player/components/timelineMotion.test.ts +27 -0
- package/src/player/components/timelineMotion.ts +62 -0
- package/src/player/components/timelineZoomInput.test.ts +438 -0
- package/src/player/components/timelineZoomInput.ts +456 -0
- package/src/player/components/useTimelineGeometry.ts +0 -3
- package/src/player/components/useTimelinePlayhead.test.tsx +97 -30
- package/src/player/components/useTimelinePlayhead.ts +53 -59
- package/src/player/components/useTimelineProviderState.tsx +16 -21
- package/src/player/components/useTimelineZoom.ts +7 -12
- package/src/player/lib/runtimeProtocol.ts +6 -0
- package/src/player/lib/thumbnailVideoDecoder.test.ts +230 -30
- package/src/player/lib/thumbnailVideoDecoder.ts +197 -54
- package/src/player/lib/timelineViewportBudgets.test.ts +1 -1
- package/src/player/lib/timelineViewportBudgets.ts +1 -1
- package/src/utils/gsapUndoRestore.browser.test.ts +4 -17
- package/dist/assets/hyperframes-player-CckOoCJ8.js +0 -473
- /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
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
181
|
+
const held = blockedRef.current;
|
|
182
|
+
const current =
|
|
179
183
|
next && held
|
|
180
|
-
? { ...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
|
-
|
|
244
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
|
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
|
-
|
|
339
|
+
failedPath,
|
|
317
340
|
studioContent,
|
|
318
|
-
readFileChangeVersion(
|
|
319
|
-
|
|
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
|
-
|
|
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
|
|
410
|
+
await drainOnePendingRef.current(pending.payload);
|
|
385
411
|
}
|
|
386
412
|
} finally {
|
|
387
413
|
drainingRef.current = false;
|
|
388
414
|
}
|
|
389
|
-
}, [
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|