@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.
- package/dist/assets/{hyperframes-player-CckOoCJ8.js → hyperframes-player-BLuyxnK7.js} +1 -1
- package/dist/assets/{index-DsEkE5_G.js → index-CWfuDCoA.js} +1 -1
- package/dist/assets/{index-BmIPrb8I.js → index-Dr3Bo9uH.js} +1 -1
- package/dist/assets/{index-Br1Whc0f.js → index-VAHDM_zL.js} +204 -204
- 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 +6498 -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 +44 -1
- package/src/components/nle/NLEPreview.tsx +7 -2
- 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/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/{chunk-E422JDF5.js.map → chunk-H2DUOXTF.js.map} +0 -0
|
@@ -6,6 +6,7 @@ import { thumbnailScheduler } from "../lib/thumbnailScheduler";
|
|
|
6
6
|
import { decodeVideoThumbnail } from "../lib/thumbnailVideoDecoder";
|
|
7
7
|
import { createHappyDomRootHarness } from "./testRootHarness";
|
|
8
8
|
import { VideoThumbnail } from "./VideoThumbnail";
|
|
9
|
+
import { markTimelineMotion } from "./timelineMotion";
|
|
9
10
|
|
|
10
11
|
vi.mock("../lib/thumbnailVideoDecoder", () => ({ decodeVideoThumbnail: vi.fn() }));
|
|
11
12
|
|
|
@@ -29,25 +30,35 @@ afterEach(() => {
|
|
|
29
30
|
// Registered after the reset above, so each test's strip unmounts before the cache is cleared.
|
|
30
31
|
const harness = createHappyDomRootHarness();
|
|
31
32
|
|
|
33
|
+
function clip(
|
|
34
|
+
videoSrc = "/api/projects/p/preview/assets/clip.mp4",
|
|
35
|
+
priority: "visible" | "overscan" = "visible",
|
|
36
|
+
) {
|
|
37
|
+
return (
|
|
38
|
+
<VideoThumbnail
|
|
39
|
+
videoSrc={videoSrc}
|
|
40
|
+
label=""
|
|
41
|
+
labelColor="#fff"
|
|
42
|
+
projectId="p"
|
|
43
|
+
sessionEpoch={1}
|
|
44
|
+
priority={priority}
|
|
45
|
+
/>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
32
49
|
async function render(width = 0, height = 40) {
|
|
33
50
|
Object.defineProperty(host, "clientWidth", { configurable: true, value: width });
|
|
34
51
|
Object.defineProperty(host, "clientHeight", { configurable: true, value: height });
|
|
35
52
|
const root = harness.mount(host);
|
|
36
53
|
await act(async () => {
|
|
37
|
-
root.render(
|
|
38
|
-
<VideoThumbnail
|
|
39
|
-
videoSrc="/api/projects/p/preview/assets/clip.mp4"
|
|
40
|
-
label=""
|
|
41
|
-
labelColor="#fff"
|
|
42
|
-
projectId="p"
|
|
43
|
-
sessionEpoch={1}
|
|
44
|
-
priority="visible"
|
|
45
|
-
/>,
|
|
46
|
-
);
|
|
54
|
+
root.render(clip());
|
|
47
55
|
await Promise.resolve();
|
|
48
56
|
});
|
|
57
|
+
return root;
|
|
49
58
|
}
|
|
50
59
|
|
|
60
|
+
const shownSources = () => [...host.querySelectorAll("img")].map((img) => img.getAttribute("src"));
|
|
61
|
+
|
|
51
62
|
describe("VideoThumbnail", () => {
|
|
52
63
|
it("does not acquire a thumbnail lease before the clip is measured", async () => {
|
|
53
64
|
vi.mocked(decodeVideoThumbnail).mockResolvedValue({
|
|
@@ -70,7 +81,7 @@ describe("VideoThumbnail", () => {
|
|
|
70
81
|
|
|
71
82
|
expect(decodeVideoThumbnail).toHaveBeenNthCalledWith(
|
|
72
83
|
1,
|
|
73
|
-
expect.objectContaining({ frameCount: 1 }),
|
|
84
|
+
expect.objectContaining({ frameCount: 1, contentVersion: "project=p&session=1" }),
|
|
74
85
|
expect.any(AbortSignal),
|
|
75
86
|
);
|
|
76
87
|
expect(decodeVideoThumbnail).toHaveBeenCalledWith(
|
|
@@ -90,7 +101,8 @@ describe("VideoThumbnail", () => {
|
|
|
90
101
|
await render(300);
|
|
91
102
|
|
|
92
103
|
const tiles = [...host.querySelectorAll("img")].map((img) => img.getAttribute("src"));
|
|
93
|
-
|
|
104
|
+
// Seven slices and the end frame: the last tile shows the end.
|
|
105
|
+
expect(tiles).toEqual(["blob:0", "blob:2", "blob:3", "blob:4", "blob:7"]);
|
|
94
106
|
});
|
|
95
107
|
|
|
96
108
|
describe("on a 10-minute clip at full zoom", () => {
|
|
@@ -249,3 +261,183 @@ describe("VideoThumbnail", () => {
|
|
|
249
261
|
expect(host.querySelector("img")).toBeNull();
|
|
250
262
|
});
|
|
251
263
|
});
|
|
264
|
+
|
|
265
|
+
describe("VideoThumbnail during a zoom", () => {
|
|
266
|
+
beforeEach(() => {
|
|
267
|
+
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
|
268
|
+
vi.mocked(decodeVideoThumbnail).mockResolvedValue({
|
|
269
|
+
value: { kind: "filmstrip", urls: ["blob:a", "blob:b"], aspect: 16 / 9 },
|
|
270
|
+
weight: 256,
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
afterEach(() => {
|
|
274
|
+
vi.useRealTimers();
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
const rest = () =>
|
|
278
|
+
act(async () => {
|
|
279
|
+
vi.runOnlyPendingTimers();
|
|
280
|
+
await Promise.resolve();
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("decodes a clip that scrolls in mid-zoom only once the timeline rests", async () => {
|
|
284
|
+
markTimelineMotion();
|
|
285
|
+
await render(440);
|
|
286
|
+
expect(decodeVideoThumbnail).not.toHaveBeenCalled();
|
|
287
|
+
await rest();
|
|
288
|
+
expect(decodeVideoThumbnail).toHaveBeenCalledWith(
|
|
289
|
+
expect.objectContaining({ frameCount: 1 }),
|
|
290
|
+
expect.any(AbortSignal),
|
|
291
|
+
);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("shows the strip it has, not the poster, while a zoom's new width decodes", async () => {
|
|
295
|
+
vi.mocked(decodeVideoThumbnail).mockImplementation(async ({ frameCount }) =>
|
|
296
|
+
frameCount === 1
|
|
297
|
+
? { value: { kind: "image", url: "blob:poster", aspect: 16 / 9 }, weight: 1 }
|
|
298
|
+
: {
|
|
299
|
+
value: { kind: "filmstrip", urls: ["blob:a", "blob:b"], aspect: 16 / 9 },
|
|
300
|
+
weight: 2,
|
|
301
|
+
},
|
|
302
|
+
);
|
|
303
|
+
await render(440);
|
|
304
|
+
expect(
|
|
305
|
+
new Set([...host.querySelectorAll("img")].map((img) => img.getAttribute("src"))),
|
|
306
|
+
).toEqual(new Set(["blob:a", "blob:b"]));
|
|
307
|
+
// The new width's decode never lands during this test.
|
|
308
|
+
vi.mocked(decodeVideoThumbnail).mockImplementation(() => new Promise(() => {}));
|
|
309
|
+
await act(async () => {
|
|
310
|
+
reportResize(880, 40);
|
|
311
|
+
await Promise.resolve();
|
|
312
|
+
});
|
|
313
|
+
await rest();
|
|
314
|
+
const shown = [...host.querySelectorAll("img")].map((img) => img.getAttribute("src"));
|
|
315
|
+
expect(shown.length).toBeGreaterThan(0);
|
|
316
|
+
expect(shown).not.toContain("blob:poster");
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("keeps holding the strip when the clip moves between on and off screen mid-decode", async () => {
|
|
320
|
+
vi.mocked(decodeVideoThumbnail).mockImplementation(async ({ frameCount }) =>
|
|
321
|
+
frameCount === 1
|
|
322
|
+
? { value: { kind: "image", url: "blob:poster", aspect: 16 / 9 }, weight: 1 }
|
|
323
|
+
: { value: { kind: "filmstrip", urls: ["blob:a", "blob:b"], aspect: 16 / 9 }, weight: 2 },
|
|
324
|
+
);
|
|
325
|
+
const root = await render(440);
|
|
326
|
+
vi.mocked(decodeVideoThumbnail).mockImplementation(() => new Promise(() => {}));
|
|
327
|
+
await act(async () => {
|
|
328
|
+
reportResize(880, 40);
|
|
329
|
+
root.render(clip(undefined, "overscan"));
|
|
330
|
+
await Promise.resolve();
|
|
331
|
+
});
|
|
332
|
+
await rest();
|
|
333
|
+
expect(new Set(shownSources())).toEqual(new Set(["blob:a", "blob:b"]));
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it("holds the strip, and decodes only the new width, when the cache is over its budget", async () => {
|
|
337
|
+
const disposed: number[] = [];
|
|
338
|
+
vi.mocked(decodeVideoThumbnail).mockImplementation(async ({ frameCount }) =>
|
|
339
|
+
frameCount === 1
|
|
340
|
+
? { value: { kind: "image", url: "blob:poster", aspect: 16 / 9 }, weight: 1 }
|
|
341
|
+
: {
|
|
342
|
+
value: { kind: "filmstrip", urls: ["blob:a", "blob:b"], aspect: 16 / 9 },
|
|
343
|
+
weight: 2,
|
|
344
|
+
dispose: () => disposed.push(frameCount),
|
|
345
|
+
},
|
|
346
|
+
);
|
|
347
|
+
await render(440);
|
|
348
|
+
const held = vi.mocked(decodeVideoThumbnail).mock.calls.at(-1)![0].frameCount;
|
|
349
|
+
// Fill the project's cache past its entry budget, so a strip without a lease is evicted.
|
|
350
|
+
await act(async () => {
|
|
351
|
+
for (let i = 0; i < 100; i++) {
|
|
352
|
+
const lease = thumbnailScheduler.acquire(
|
|
353
|
+
{
|
|
354
|
+
key: `filler-${i}`,
|
|
355
|
+
projectId: "p",
|
|
356
|
+
sessionEpoch: 1,
|
|
357
|
+
kind: "image",
|
|
358
|
+
priority: "visible",
|
|
359
|
+
load: async () => ({
|
|
360
|
+
value: { kind: "image", url: `blob:f${i}`, aspect: 1 },
|
|
361
|
+
weight: 1,
|
|
362
|
+
}),
|
|
363
|
+
},
|
|
364
|
+
() => {},
|
|
365
|
+
);
|
|
366
|
+
for (let tick = 0; tick < 5; tick++) await Promise.resolve();
|
|
367
|
+
lease.release();
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
const decodes = vi.mocked(decodeVideoThumbnail).mock.calls.length;
|
|
371
|
+
vi.mocked(decodeVideoThumbnail).mockImplementation(() => new Promise(() => {}));
|
|
372
|
+
await act(async () => {
|
|
373
|
+
reportResize(880, 40);
|
|
374
|
+
await Promise.resolve();
|
|
375
|
+
});
|
|
376
|
+
await rest();
|
|
377
|
+
const newWidths = vi
|
|
378
|
+
.mocked(decodeVideoThumbnail)
|
|
379
|
+
.mock.calls.slice(decodes)
|
|
380
|
+
.map(([r]) => r.frameCount);
|
|
381
|
+
expect(newWidths).not.toContain(held);
|
|
382
|
+
expect(disposed).toEqual([]);
|
|
383
|
+
expect(new Set(shownSources())).toEqual(new Set(["blob:a", "blob:b"]));
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it("never holds one clip's strip for another while the other decodes", async () => {
|
|
387
|
+
vi.mocked(decodeVideoThumbnail).mockImplementation(({ source }) =>
|
|
388
|
+
source.endsWith("clip.mp4")
|
|
389
|
+
? Promise.resolve({
|
|
390
|
+
value: { kind: "filmstrip", urls: ["blob:a", "blob:b"], aspect: 16 / 9 },
|
|
391
|
+
weight: 2,
|
|
392
|
+
})
|
|
393
|
+
: new Promise(() => {}),
|
|
394
|
+
);
|
|
395
|
+
const root = await render(440);
|
|
396
|
+
expect(shownSources()).toContain("blob:a");
|
|
397
|
+
await act(async () => {
|
|
398
|
+
root.render(clip("/api/projects/p/preview/assets/other.mp4"));
|
|
399
|
+
await Promise.resolve();
|
|
400
|
+
});
|
|
401
|
+
await rest();
|
|
402
|
+
expect(shownSources()).toEqual([]);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it("lets go of the strip it held once the clip narrows to one frame", async () => {
|
|
406
|
+
vi.mocked(decodeVideoThumbnail).mockImplementation(async ({ frameCount }) =>
|
|
407
|
+
frameCount === 1
|
|
408
|
+
? { value: { kind: "image", url: "blob:poster", aspect: 16 / 9 }, weight: 1 }
|
|
409
|
+
: { value: { kind: "filmstrip", urls: ["blob:a", "blob:b"], aspect: 16 / 9 }, weight: 2 },
|
|
410
|
+
);
|
|
411
|
+
await render(440);
|
|
412
|
+
await act(async () => {
|
|
413
|
+
reportResize(50, 40);
|
|
414
|
+
await Promise.resolve();
|
|
415
|
+
});
|
|
416
|
+
await rest();
|
|
417
|
+
expect(new Set(shownSources())).toEqual(new Set(["blob:poster"]));
|
|
418
|
+
// Widening again shows the poster until the new width lands, not the strip it once held.
|
|
419
|
+
vi.mocked(decodeVideoThumbnail).mockImplementation(() => new Promise(() => {}));
|
|
420
|
+
await act(async () => {
|
|
421
|
+
reportResize(880, 40);
|
|
422
|
+
await Promise.resolve();
|
|
423
|
+
});
|
|
424
|
+
await rest();
|
|
425
|
+
expect(new Set(shownSources())).toEqual(new Set(["blob:poster"]));
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it("keeps its filmstrip while the zoom resizes it, and decodes the new width at rest", async () => {
|
|
429
|
+
await render(440);
|
|
430
|
+
vi.mocked(decodeVideoThumbnail).mockClear();
|
|
431
|
+
await act(async () => {
|
|
432
|
+
markTimelineMotion();
|
|
433
|
+
reportResize(880, 40);
|
|
434
|
+
await Promise.resolve();
|
|
435
|
+
});
|
|
436
|
+
expect(decodeVideoThumbnail).not.toHaveBeenCalled();
|
|
437
|
+
await rest();
|
|
438
|
+
expect(decodeVideoThumbnail).toHaveBeenCalledWith(
|
|
439
|
+
expect.objectContaining({ frameCount: 16 }),
|
|
440
|
+
expect.any(AbortSignal),
|
|
441
|
+
);
|
|
442
|
+
});
|
|
443
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { memo, useMemo } from "react";
|
|
1
|
+
import { memo, useMemo, useState } from "react";
|
|
2
2
|
import { useThumbnailLease } from "../../hooks/useThumbnailLease";
|
|
3
3
|
import { useThumbnailStripSize } from "../../hooks/useThumbnailStripSize";
|
|
4
4
|
import {
|
|
@@ -8,7 +8,12 @@ import {
|
|
|
8
8
|
} from "../lib/thumbnailScheduler";
|
|
9
9
|
import { decodeVideoThumbnail } from "../lib/thumbnailVideoDecoder";
|
|
10
10
|
import { ThumbnailTiles } from "./ThumbnailTiles";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
computeThumbnailStrip,
|
|
13
|
+
quantizeThumbnailFrameCount,
|
|
14
|
+
thumbnailFrameForTile,
|
|
15
|
+
} from "./thumbnailUtils";
|
|
16
|
+
import { useValueAtRest } from "./timelineMotion";
|
|
12
17
|
|
|
13
18
|
interface VideoThumbnailProps {
|
|
14
19
|
videoSrc: string;
|
|
@@ -54,6 +59,7 @@ function createVideoThumbnailRequest(
|
|
|
54
59
|
decodeVideoThumbnail(
|
|
55
60
|
{
|
|
56
61
|
source: videoSrc,
|
|
62
|
+
contentVersion: createThumbnailKey({ project: projectId, session: sessionEpoch }),
|
|
57
63
|
sourceStart,
|
|
58
64
|
sourceRangeDuration: sourceRangeDuration ?? duration,
|
|
59
65
|
frameCount,
|
|
@@ -67,13 +73,31 @@ function createVideoThumbnailRequest(
|
|
|
67
73
|
function selectThumbnailSnapshot(
|
|
68
74
|
poster: ThumbnailSnapshot,
|
|
69
75
|
rich: ThumbnailSnapshot,
|
|
76
|
+
shown: ThumbnailSnapshot,
|
|
70
77
|
): ThumbnailSnapshot {
|
|
71
78
|
if (rich.status === "ready") return rich;
|
|
79
|
+
if (shown.status === "ready") return shown;
|
|
72
80
|
if (poster.status === "ready") return poster;
|
|
73
81
|
if (rich.status === "loading" || poster.status === "loading") return { status: "loading" };
|
|
74
82
|
return poster;
|
|
75
83
|
}
|
|
76
84
|
|
|
85
|
+
type VideoThumbnailRequest = ReturnType<typeof createVideoThumbnailRequest>;
|
|
86
|
+
|
|
87
|
+
function useVideoThumbnailSnapshot(
|
|
88
|
+
poster: VideoThumbnailRequest | null,
|
|
89
|
+
rich: VideoThumbnailRequest | null,
|
|
90
|
+
media: string,
|
|
91
|
+
): ThumbnailSnapshot {
|
|
92
|
+
const posterSnapshot = useThumbnailLease(poster);
|
|
93
|
+
const richSnapshot = useThumbnailLease(rich);
|
|
94
|
+
const [shown, setShown] = useState({ media, request: rich });
|
|
95
|
+
const settled = richSnapshot.status === "ready" || rich === null;
|
|
96
|
+
if (settled && shown.request !== rich) setShown({ media, request: rich });
|
|
97
|
+
const shownSnapshot = useThumbnailLease(shown.media === media ? shown.request : null);
|
|
98
|
+
return selectThumbnailSnapshot(posterSnapshot, richSnapshot, shownSnapshot);
|
|
99
|
+
}
|
|
100
|
+
|
|
77
101
|
/** Sparse, bounded video frames supplied by the shared thumbnail scheduler. */
|
|
78
102
|
export const VideoThumbnail = memo(function VideoThumbnail({
|
|
79
103
|
videoSrc,
|
|
@@ -87,8 +111,10 @@ export const VideoThumbnail = memo(function VideoThumbnail({
|
|
|
87
111
|
priority = "visible",
|
|
88
112
|
}: VideoThumbnailProps) {
|
|
89
113
|
const [container, setContainerRef, watchGap] = useThumbnailStripSize();
|
|
90
|
-
const requestFrameCount =
|
|
91
|
-
|
|
114
|
+
const requestFrameCount = useValueAtRest(
|
|
115
|
+
quantizeThumbnailFrameCount(
|
|
116
|
+
computeThumbnailStrip(container.width, 16 / 9, container.height).frameCount,
|
|
117
|
+
),
|
|
92
118
|
);
|
|
93
119
|
const requestProps = useMemo(
|
|
94
120
|
() => ({
|
|
@@ -110,10 +136,12 @@ export const VideoThumbnail = memo(function VideoThumbnail({
|
|
|
110
136
|
() => createVideoThumbnailRequest(requestProps, requestFrameCount, true),
|
|
111
137
|
[requestFrameCount, requestProps],
|
|
112
138
|
);
|
|
113
|
-
const measured = container.width > 0;
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
139
|
+
const measured = useValueAtRest(container.width > 0);
|
|
140
|
+
const snapshot = useVideoThumbnailSnapshot(
|
|
141
|
+
measured ? posterRequest : null,
|
|
142
|
+
measured && requestFrameCount > 1 ? richRequest : null,
|
|
143
|
+
posterRequest.key,
|
|
144
|
+
);
|
|
117
145
|
const value = snapshot.status === "ready" ? snapshot.value : null;
|
|
118
146
|
const urls =
|
|
119
147
|
value?.kind === "filmstrip" ? value.urls : value?.kind === "image" ? [value.url] : [];
|
|
@@ -130,7 +158,7 @@ export const VideoThumbnail = memo(function VideoThumbnail({
|
|
|
130
158
|
watchGap={watchGap}
|
|
131
159
|
>
|
|
132
160
|
{(index) => {
|
|
133
|
-
const src = urls[
|
|
161
|
+
const src = urls[thumbnailFrameForTile(index, frameCount, urls.length)];
|
|
134
162
|
return (
|
|
135
163
|
<div
|
|
136
164
|
key={index}
|
|
@@ -4,7 +4,9 @@ import {
|
|
|
4
4
|
encodePreviewPath,
|
|
5
5
|
resolveMediaPreviewUrl,
|
|
6
6
|
quantizeThumbnailFrameCount,
|
|
7
|
+
thumbnailFrameForTile,
|
|
7
8
|
} from "./thumbnailUtils";
|
|
9
|
+
import { MAX_VISIBLE_THUMBNAIL_FRAMES } from "../lib/timelineViewportBudgets";
|
|
8
10
|
|
|
9
11
|
describe("computeThumbnailStrip", () => {
|
|
10
12
|
it("sizes tiles by aspect ratio at the clip height", () => {
|
|
@@ -51,15 +53,27 @@ describe("computeThumbnailStrip", () => {
|
|
|
51
53
|
});
|
|
52
54
|
});
|
|
53
55
|
|
|
56
|
+
describe("thumbnailFrameForTile", () => {
|
|
57
|
+
it("shows the clip's last frame in the last tile, and the slice under each other tile's centre", () => {
|
|
58
|
+
// 8 slices and the end frame.
|
|
59
|
+
expect([0, 1, 2].map((tile) => thumbnailFrameForTile(tile, 3, 9))).toEqual([1, 4, 8]);
|
|
60
|
+
// 2 slices and the end frame across 4 tiles.
|
|
61
|
+
expect([0, 1, 2, 3].map((tile) => thumbnailFrameForTile(tile, 4, 3))).toEqual([0, 0, 1, 2]);
|
|
62
|
+
expect(thumbnailFrameForTile(0, 1, 9)).toBe(4);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
54
66
|
describe("quantizeThumbnailFrameCount", () => {
|
|
55
67
|
it("uses doubling buckets and never exceeds the 4K geometry ceiling", () => {
|
|
56
68
|
expect(quantizeThumbnailFrameCount(5)).toBe(8);
|
|
57
69
|
expect(quantizeThumbnailFrameCount(32)).toBe(32);
|
|
58
|
-
expect(quantizeThumbnailFrameCount(34)).toBe(33);
|
|
59
70
|
});
|
|
60
71
|
|
|
61
|
-
it("caps decode requests at the
|
|
62
|
-
|
|
72
|
+
it("caps decode requests at the largest step within the visible-frame budget", () => {
|
|
73
|
+
// A step that is not a power of two would share no frames with the step below it.
|
|
74
|
+
expect(MAX_VISIBLE_THUMBNAIL_FRAMES).toBeGreaterThan(32);
|
|
75
|
+
expect(quantizeThumbnailFrameCount(34)).toBe(32);
|
|
76
|
+
expect(quantizeThumbnailFrameCount(124)).toBe(32);
|
|
63
77
|
});
|
|
64
78
|
});
|
|
65
79
|
|
|
@@ -11,7 +11,23 @@ export interface ThumbnailStripLayout {
|
|
|
11
11
|
/** Quantize request identities so a pixel-by-pixel resize does not thrash the cache. */
|
|
12
12
|
export function quantizeThumbnailFrameCount(frameCount: number): number {
|
|
13
13
|
const safeCount = Math.max(1, Number.isFinite(frameCount) ? Math.ceil(frameCount) : 1);
|
|
14
|
-
|
|
14
|
+
const cap = 2 ** Math.floor(Math.log2(MAX_VISIBLE_THUMBNAIL_FRAMES));
|
|
15
|
+
return Math.min(cap, 2 ** Math.ceil(Math.log2(safeCount)));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The decoded frame tile `index` of `tileCount` shows, of a strip's `frameCount`: the last tile
|
|
20
|
+
* shows the clip's last frame; the others the slice (videoThumbnailTimestamps) holding their centre.
|
|
21
|
+
*/
|
|
22
|
+
export function thumbnailFrameForTile(
|
|
23
|
+
index: number,
|
|
24
|
+
tileCount: number,
|
|
25
|
+
frameCount: number,
|
|
26
|
+
): number {
|
|
27
|
+
if (frameCount < 2) return 0;
|
|
28
|
+
if (tileCount > 1 && index >= tileCount - 1) return frameCount - 1;
|
|
29
|
+
const slices = frameCount - 1;
|
|
30
|
+
return Math.min(slices - 1, Math.floor(((index + 0.5) * slices) / tileCount));
|
|
15
31
|
}
|
|
16
32
|
|
|
17
33
|
/**
|
|
@@ -27,13 +27,13 @@ import { resolveInsertRow } from "./timelineCollision";
|
|
|
27
27
|
import { getTimelineRenderTimeRange } from "./timelineViewportGeometry";
|
|
28
28
|
|
|
29
29
|
describe("horizontal timeline window", () => {
|
|
30
|
-
it("adds the shared
|
|
30
|
+
it("adds the shared half-viewport overscan on each side and clamps to duration", () => {
|
|
31
31
|
expect(getTimelineRenderTimeRange({ scrollLeft: 300, clientWidth: 500 }, 100, 200, 20)).toEqual(
|
|
32
|
-
{ start: 0, end:
|
|
32
|
+
{ start: 0, end: 8.5 },
|
|
33
33
|
);
|
|
34
34
|
expect(
|
|
35
35
|
getTimelineRenderTimeRange({ scrollLeft: 1_900, clientWidth: 500 }, 100, 200, 20),
|
|
36
|
-
).toEqual({ start:
|
|
36
|
+
).toEqual({ start: 14.5, end: 20 });
|
|
37
37
|
});
|
|
38
38
|
|
|
39
39
|
it("generates globally aligned ticks directly inside the bounded window", () => {
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { afterEach, expect, it, vi } from "vitest";
|
|
2
|
+
import { markTimelineMotion, TIMELINE_REST_MS, whenTimelineIdle } from "./timelineMotion";
|
|
3
|
+
|
|
4
|
+
afterEach(() => {
|
|
5
|
+
vi.runOnlyPendingTimers();
|
|
6
|
+
vi.useRealTimers();
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("waits for a zoom to rest before resolving, and resolves at once when nothing moves", async () => {
|
|
10
|
+
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "requestIdleCallback"] });
|
|
11
|
+
markTimelineMotion();
|
|
12
|
+
let idle = false;
|
|
13
|
+
void whenTimelineIdle().then(() => (idle = true));
|
|
14
|
+
await vi.advanceTimersByTimeAsync(TIMELINE_REST_MS - 50);
|
|
15
|
+
expect(idle).toBe(false);
|
|
16
|
+
// Another zoom step pushes the rest back.
|
|
17
|
+
markTimelineMotion();
|
|
18
|
+
await vi.advanceTimersByTimeAsync(TIMELINE_REST_MS - 50);
|
|
19
|
+
expect(idle).toBe(false);
|
|
20
|
+
await vi.advanceTimersByTimeAsync(TIMELINE_REST_MS);
|
|
21
|
+
expect(idle).toBe(true);
|
|
22
|
+
|
|
23
|
+
let again = false;
|
|
24
|
+
void whenTimelineIdle().then(() => (again = true));
|
|
25
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
26
|
+
expect(again).toBe(true);
|
|
27
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { useState, useSyncExternalStore } from "react";
|
|
2
|
+
|
|
3
|
+
/** How long the timeline must go without a zoom step to count as at rest. */
|
|
4
|
+
export const TIMELINE_REST_MS = 150;
|
|
5
|
+
|
|
6
|
+
let moving = false;
|
|
7
|
+
let restTimer: ReturnType<typeof setTimeout> | null = null;
|
|
8
|
+
const listeners = new Set<() => void>();
|
|
9
|
+
|
|
10
|
+
const emit = () => listeners.forEach((listener) => listener());
|
|
11
|
+
|
|
12
|
+
/** A zoom step: work that only matters at rest (redraws, decodes) waits for the zoom to end. */
|
|
13
|
+
export function markTimelineMotion(): void {
|
|
14
|
+
if (!moving) {
|
|
15
|
+
moving = true;
|
|
16
|
+
emit();
|
|
17
|
+
}
|
|
18
|
+
if (restTimer) clearTimeout(restTimer);
|
|
19
|
+
restTimer = setTimeout(() => {
|
|
20
|
+
restTimer = null;
|
|
21
|
+
moving = false;
|
|
22
|
+
emit();
|
|
23
|
+
}, TIMELINE_REST_MS);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const onIdle = (callback: () => void) => {
|
|
27
|
+
if (typeof requestIdleCallback === "function") requestIdleCallback(callback, { timeout: 1000 });
|
|
28
|
+
else setTimeout(callback, 0);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** Resolves at an idle moment with no zoom moving the timeline: long work waits there, not mid-zoom. */
|
|
32
|
+
export function whenTimelineIdle(): Promise<void> {
|
|
33
|
+
return new Promise((resolve) => {
|
|
34
|
+
const check = () => {
|
|
35
|
+
if (!moving) return resolve();
|
|
36
|
+
const stop = subscribeTimelineMotion(() => {
|
|
37
|
+
if (moving) return;
|
|
38
|
+
stop();
|
|
39
|
+
onIdle(check);
|
|
40
|
+
});
|
|
41
|
+
};
|
|
42
|
+
onIdle(check);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function isTimelineMoving(): boolean {
|
|
47
|
+
return moving;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function subscribeTimelineMotion(listener: () => void): () => void {
|
|
51
|
+
listeners.add(listener);
|
|
52
|
+
return () => listeners.delete(listener);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** `value` at rest; while the timeline moves, the value it had when it last rested. */
|
|
56
|
+
export function useValueAtRest<T>(value: T): T {
|
|
57
|
+
// Re-renders only when a zoom starts and when it rests.
|
|
58
|
+
const moving = useSyncExternalStore(subscribeTimelineMotion, isTimelineMoving, () => false);
|
|
59
|
+
const [held, setHeld] = useState(value);
|
|
60
|
+
if (!moving && !Object.is(held, value)) setHeld(value);
|
|
61
|
+
return moving ? held : value;
|
|
62
|
+
}
|