@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
@@ -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
- expect(tiles).toEqual(["blob:0", "blob:2", "blob:4", "blob:5", "blob:7"]);
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 { computeThumbnailStrip, quantizeThumbnailFrameCount } from "./thumbnailUtils";
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 = quantizeThumbnailFrameCount(
91
- computeThumbnailStrip(container.width, 16 / 9, container.height).frameCount,
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 posterSnapshot = useThumbnailLease(measured ? posterRequest : null);
115
- const richSnapshot = useThumbnailLease(measured && requestFrameCount > 1 ? richRequest : null);
116
- const snapshot = selectThumbnailSnapshot(posterSnapshot, richSnapshot);
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[Math.round((index * (urls.length - 1)) / Math.max(1, frameCount - 1))];
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 shared visible-frame budget", () => {
62
- expect(quantizeThumbnailFrameCount(124)).toBe(33);
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
- return Math.min(MAX_VISIBLE_THUMBNAIL_FRAMES, 2 ** Math.ceil(Math.log2(safeCount)));
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 quarter-viewport overscan on each side and clamps to duration", () => {
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: 7.25 },
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: 15.75, end: 20 });
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
+ }