@hyperframes/studio 0.7.93 → 0.7.95

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 (59) hide show
  1. package/dist/assets/{hyperframes-player-NSdcoOcK.js → hyperframes-player-C1o3TZ1w.js} +1 -1
  2. package/dist/assets/{index-Dp1zVb9X.js → index-4KcC7fDo.js} +195 -195
  3. package/dist/assets/{index-wS6f7F5q.js → index-Bl6x228Z.js} +1 -1
  4. package/dist/assets/{index-CCGnc_0M.js → index-DR4Dsw8D.js} +1 -1
  5. package/dist/assets/index-tBPidglp.css +1 -0
  6. package/dist/index.d.ts +27 -7
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +2644 -1837
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/components/EditorShell.selectionSync.test.tsx +109 -0
  12. package/src/components/EditorShell.tsx +31 -5
  13. package/src/components/TimelineToolbar.test.tsx +20 -1
  14. package/src/components/TimelineToolbar.tsx +29 -1
  15. package/src/hooks/useRenderClipContent.test.ts +77 -2
  16. package/src/hooks/useRenderClipContent.ts +78 -14
  17. package/src/hooks/useThumbnailLease.test.tsx +150 -0
  18. package/src/hooks/useThumbnailLease.ts +44 -0
  19. package/src/hooks/useTimelineSelectionPreviewSync.test.tsx +79 -5
  20. package/src/hooks/useTimelineSelectionPreviewSync.ts +40 -13
  21. package/src/player/components/AudioWaveform.test.tsx +53 -0
  22. package/src/player/components/AudioWaveform.tsx +137 -140
  23. package/src/player/components/CompositionThumbnail.test.ts +33 -10
  24. package/src/player/components/CompositionThumbnail.tsx +78 -61
  25. package/src/player/components/ImageThumbnail.test.tsx +26 -14
  26. package/src/player/components/ImageThumbnail.tsx +58 -101
  27. package/src/player/components/Timeline.tsx +20 -19
  28. package/src/player/components/TimelineGestureOverlay.tsx +3 -0
  29. package/src/player/components/TimelineLanes.test.tsx +24 -2
  30. package/src/player/components/TimelineLanes.tsx +12 -12
  31. package/src/player/components/TimelineTypes.ts +6 -0
  32. package/src/player/components/VideoThumbnail.test.tsx +46 -100
  33. package/src/player/components/VideoThumbnail.tsx +74 -156
  34. package/src/player/components/thumbnailUtils.test.ts +35 -0
  35. package/src/player/components/thumbnailUtils.ts +86 -8
  36. package/src/player/components/timelineClipChildren.test.ts +31 -0
  37. package/src/player/components/timelineClipChildren.tsx +21 -2
  38. package/src/player/components/timelineLaneProps.ts +3 -0
  39. package/src/player/components/useTimelineClipRenderWindow.ts +9 -2
  40. package/src/player/hooks/useTimelinePlayer.ts +14 -9
  41. package/src/player/lib/mediaProbe.test.ts +143 -0
  42. package/src/player/lib/mediaProbe.ts +117 -25
  43. package/src/player/lib/thumbnailPolicy.test.ts +14 -0
  44. package/src/player/lib/thumbnailPolicy.ts +24 -0
  45. package/src/player/lib/thumbnailScheduler.test.ts +388 -0
  46. package/src/player/lib/thumbnailScheduler.ts +483 -0
  47. package/src/player/lib/thumbnailVideoDecoder.test.ts +127 -0
  48. package/src/player/lib/thumbnailVideoDecoder.ts +170 -0
  49. package/src/player/lib/timelineViewportBudgets.ts +2 -0
  50. package/src/player/store/playerStore.ts +3 -1
  51. package/src/player/store/thumbnailSlice.ts +18 -0
  52. package/src/utils/frameCapture.test.ts +1 -1
  53. package/src/utils/frameCapture.ts +1 -0
  54. package/src/utils/projectRouting.test.ts +1 -1
  55. package/src/utils/studioSelectionSnapshot.test.ts +1 -1
  56. package/src/utils/studioSelectionSnapshot.ts +1 -0
  57. package/src/utils/studioUiPreferences.test.ts +14 -0
  58. package/src/utils/studioUiPreferences.ts +6 -0
  59. package/dist/assets/index-D78KEjgB.css +0 -1
@@ -1,6 +1,20 @@
1
1
  import { type ReactNode } from "react";
2
2
  import { usePlayerStore, type TimelineElement } from "../store/playerStore";
3
+ import type { TimelineTimeRange } from "../lib/timelineClipIndex";
3
4
  import type { TrackVisualStyle } from "./timelineIcons";
5
+ import type { TimelineClipRenderContext } from "./TimelineTypes";
6
+
7
+ export function resolveClipRenderContext(
8
+ element: TimelineElement,
9
+ visibleTimeRange: TimelineTimeRange,
10
+ interactive: boolean,
11
+ ): TimelineClipRenderContext {
12
+ if (interactive) return { priority: "interaction", rich: true };
13
+ const visible =
14
+ element.start < visibleTimeRange.end &&
15
+ element.start + element.duration > visibleTimeRange.start;
16
+ return { priority: visible ? "visible" : "overscan", rich: false };
17
+ }
4
18
 
5
19
  function ClipLintDot({ element }: { element: TimelineElement }) {
6
20
  const lint = usePlayerStore((s) => s.lintFindingsByElement.get(element.key ?? element.id));
@@ -18,9 +32,14 @@ export function renderClipChildren(
18
32
  element: TimelineElement,
19
33
  clipStyle: TrackVisualStyle,
20
34
  renderClipContent:
21
- | ((element: TimelineElement, style: { clip: string; label: string }) => ReactNode)
35
+ | ((
36
+ element: TimelineElement,
37
+ style: { clip: string; label: string },
38
+ context: TimelineClipRenderContext,
39
+ ) => ReactNode)
22
40
  | undefined,
23
41
  renderClipOverlay: ((element: TimelineElement) => ReactNode) | undefined,
42
+ context: TimelineClipRenderContext = { priority: "visible", rich: false },
24
43
  ): ReactNode {
25
44
  return (
26
45
  <>
@@ -31,7 +50,7 @@ export function renderClipChildren(
31
50
  // diamonds hang outside its bounds), so the thumbnail layer must clip
32
51
  // itself to the clip's rounded corners or sharp corners poke out.
33
52
  <div className="absolute inset-0 overflow-hidden" style={{ borderRadius: "inherit" }}>
34
- {renderClipContent(element, clipStyle)}
53
+ {renderClipContent(element, clipStyle, context)}
35
54
  </div>
36
55
  )}
37
56
  </>
@@ -10,6 +10,7 @@ import type { TimelineClipIndex, TimelineTimeRange } from "../lib/timelineClipIn
10
10
  import type { TimelineRowGeometry } from "./timelineLayout";
11
11
  import type { TimelineVirtualRow } from "./useTimelineVirtualRows";
12
12
  import type { TimelineLogicalRow } from "./timelineKeyboardNavigation";
13
+ import type { TimelineClipRenderContext } from "./TimelineTypes";
13
14
 
14
15
  /**
15
16
  * Props shared by the scroll container ({@link import("./TimelineCanvas")}) and
@@ -33,6 +34,7 @@ export interface TimelineLaneBaseProps {
33
34
  rowsVirtualized: boolean;
34
35
  clipIndex: TimelineClipIndex;
35
36
  renderTimeRange: TimelineTimeRange;
37
+ visibleTimeRange: TimelineTimeRange;
36
38
  pinnedClipIdentities: ReadonlySet<string>;
37
39
  trackOrder: number[];
38
40
  tracks: [number, TimelineElement[]][];
@@ -48,6 +50,7 @@ export interface TimelineLaneBaseProps {
48
50
  renderClipContent?: (
49
51
  element: TimelineElement,
50
52
  style: { clip: string; label: string },
53
+ context: TimelineClipRenderContext,
51
54
  ) => ReactNode;
52
55
  renderClipOverlay?: (element: TimelineElement) => ReactNode;
53
56
  onDrillDown?: (element: TimelineElement) => void;
@@ -1,6 +1,9 @@
1
1
  import { useMemo } from "react";
2
2
  import { createTimelineClipIndex } from "../lib/timelineClipIndex";
3
- import { getTimelineRenderTimeRange } from "./timelineViewportGeometry";
3
+ import {
4
+ getTimelineRenderTimeRange,
5
+ getTimelineVisibleTimeRange,
6
+ } from "./timelineViewportGeometry";
4
7
  import type { TimelineScrollViewportSnapshot } from "./useTimelineScrollViewport";
5
8
 
6
9
  interface UseTimelineClipRenderWindowInput {
@@ -37,6 +40,10 @@ export function useTimelineClipRenderWindow({
37
40
  () => getTimelineRenderTimeRange(viewport, pixelsPerSecond, contentOrigin, duration),
38
41
  [contentOrigin, duration, pixelsPerSecond, viewport],
39
42
  );
43
+ const visibleTimeRange = useMemo(
44
+ () => getTimelineVisibleTimeRange(viewport, pixelsPerSecond, contentOrigin, duration),
45
+ [contentOrigin, duration, pixelsPerSecond, viewport],
46
+ );
40
47
  const pinnedClipIdentities = useMemo(
41
48
  () =>
42
49
  new Set(
@@ -60,5 +67,5 @@ export function useTimelineClipRenderWindow({
60
67
  selectedElementId,
61
68
  ],
62
69
  );
63
- return { clipIndex, renderTimeRange, pinnedClipIdentities };
70
+ return { clipIndex, renderTimeRange, visibleTimeRange, pinnedClipIdentities };
64
71
  }
@@ -86,6 +86,7 @@ export function useTimelinePlayer() {
86
86
  state.duration,
87
87
  resolvedDuration,
88
88
  ),
89
+ state.timelineProjectId,
89
90
  ),
90
91
  );
91
92
 
@@ -105,15 +106,19 @@ export function useTimelinePlayer() {
105
106
 
106
107
  // Asynchronously enrich media elements still missing sourceDuration
107
108
  // (header-only probe, cheap), applying each resolved value to the store.
108
- void probeMissingSourceDurations(mergedElements, (key, durationSeconds) => {
109
- usePlayerStore.setState((state) => {
110
- const idx = state.elements.findIndex((e) => (e.key ?? e.id) === key);
111
- if (idx === -1 || state.elements[idx].sourceDuration != null) return {};
112
- const patched = state.elements.slice();
113
- patched[idx] = { ...state.elements[idx], sourceDuration: durationSeconds };
114
- return { elements: patched };
115
- });
116
- });
109
+ void probeMissingSourceDurations(
110
+ mergedElements,
111
+ state.timelineProjectId,
112
+ (key, durationSeconds) => {
113
+ usePlayerStore.setState((state) => {
114
+ const idx = state.elements.findIndex((e) => (e.key ?? e.id) === key);
115
+ if (idx === -1 || state.elements[idx].sourceDuration != null) return {};
116
+ const patched = state.elements.slice();
117
+ patched[idx] = { ...state.elements[idx], sourceDuration: durationSeconds };
118
+ return { elements: patched };
119
+ });
120
+ },
121
+ );
117
122
  },
118
123
  [setElements, setTimelineReady, setDuration],
119
124
  );
@@ -0,0 +1,143 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import {
5
+ applyCachedSourceDurations,
6
+ getMediaProbeDiagnostics,
7
+ probeMediaUrl,
8
+ probeMissingSourceDurations,
9
+ resetMediaProbeRegistry,
10
+ } from "./mediaProbe";
11
+
12
+ const dispose = vi.fn();
13
+ const getDurationFromMetadata = vi.fn(async () => 5);
14
+ const requestedSources: string[] = [];
15
+
16
+ vi.mock("mediabunny", () => ({
17
+ ALL_FORMATS: {},
18
+ UrlSource: class {
19
+ constructor(readonly url: string) {
20
+ requestedSources.push(url);
21
+ }
22
+ },
23
+ Input: class {
24
+ getDurationFromMetadata = getDurationFromMetadata;
25
+ getPrimaryVideoTrack = vi.fn(async () => ({ displayWidth: 640, displayHeight: 360 }));
26
+ getAudioTracks = vi.fn(async () => []);
27
+ dispose = dispose;
28
+ },
29
+ }));
30
+
31
+ beforeEach(() => {
32
+ resetMediaProbeRegistry();
33
+ vi.clearAllMocks();
34
+ requestedSources.length = 0;
35
+ getDurationFromMetadata.mockResolvedValue(5);
36
+ });
37
+
38
+ afterEach(() => {
39
+ vi.useRealTimers();
40
+ });
41
+
42
+ describe("media probe registry", () => {
43
+ it("deduplicates and caches successful probes", async () => {
44
+ const [first, second] = await Promise.all([
45
+ probeMediaUrl("/video.mp4"),
46
+ probeMediaUrl("/video.mp4"),
47
+ ]);
48
+ expect(first).toEqual(second);
49
+ expect(getDurationFromMetadata).toHaveBeenCalledTimes(1);
50
+ expect(dispose).toHaveBeenCalledTimes(1);
51
+ expect(getMediaProbeDiagnostics()).toEqual({ cached: 1, failed: 0, inflight: 0 });
52
+ });
53
+
54
+ it("bounds retained successes to the configured registry count", async () => {
55
+ for (let index = 0; index < 513; index++) {
56
+ await probeMediaUrl(`/video-${index}.mp4`);
57
+ }
58
+ expect(getMediaProbeDiagnostics().cached).toBe(512);
59
+ });
60
+
61
+ it("limits concurrent metadata probes and drains the queue", async () => {
62
+ const resolvers: Array<(duration: number) => void> = [];
63
+ getDurationFromMetadata.mockImplementation(
64
+ () => new Promise<number>((resolve) => resolvers.push(resolve)),
65
+ );
66
+
67
+ const probes = Array.from({ length: 5 }, (_, index) => probeMediaUrl(`/queued-${index}.mp4`));
68
+ await Promise.resolve();
69
+ await Promise.resolve();
70
+ expect(getDurationFromMetadata).toHaveBeenCalledTimes(4);
71
+
72
+ resolvers[0]?.(5);
73
+ await vi.waitFor(() => expect(getDurationFromMetadata).toHaveBeenCalledTimes(5));
74
+
75
+ for (const resolve of resolvers.slice(1)) resolve(5);
76
+ await expect(Promise.all(probes)).resolves.toHaveLength(5);
77
+ expect(getMediaProbeDiagnostics()).toEqual({ cached: 5, failed: 0, inflight: 0 });
78
+ });
79
+
80
+ it("retries failures only after the failure TTL", async () => {
81
+ vi.useFakeTimers();
82
+ getDurationFromMetadata.mockRejectedValue(new Error("bad source"));
83
+ await expect(probeMediaUrl("/bad.mp4")).resolves.toBeNull();
84
+ await expect(probeMediaUrl("/bad.mp4")).resolves.toBeNull();
85
+ expect(getDurationFromMetadata).toHaveBeenCalledTimes(1);
86
+
87
+ vi.advanceTimersByTime(30_001);
88
+ await expect(probeMediaUrl("/bad.mp4")).resolves.toBeNull();
89
+ expect(getDurationFromMetadata).toHaveBeenCalledTimes(2);
90
+ });
91
+
92
+ it("probes same-origin rooted media through the active project preview", async () => {
93
+ const apply = vi.fn();
94
+ await probeMissingSourceDurations(
95
+ [
96
+ {
97
+ id: "clip",
98
+ tag: "video",
99
+ src: `${window.location.origin}/assets/clip.mp4`,
100
+ },
101
+ ],
102
+ "project-a",
103
+ apply,
104
+ );
105
+
106
+ expect(requestedSources).toEqual([
107
+ `${window.location.origin}/api/projects/project-a/preview/assets/clip.mp4`,
108
+ ]);
109
+ expect(apply).toHaveBeenCalledWith("clip", 5);
110
+ expect(
111
+ applyCachedSourceDurations(
112
+ [
113
+ {
114
+ id: "clip",
115
+ tag: "video",
116
+ src: `${window.location.origin}/assets/clip.mp4`,
117
+ },
118
+ ],
119
+ "project-a",
120
+ ),
121
+ ).toEqual([
122
+ {
123
+ id: "clip",
124
+ tag: "video",
125
+ src: `${window.location.origin}/assets/clip.mp4`,
126
+ sourceDuration: 5,
127
+ },
128
+ ]);
129
+
130
+ await probeMissingSourceDurations(
131
+ [
132
+ {
133
+ id: "clip",
134
+ tag: "video",
135
+ src: `${window.location.origin}/assets/clip.mp4`,
136
+ },
137
+ ],
138
+ "project-a",
139
+ apply,
140
+ );
141
+ expect(requestedSources).toHaveLength(1);
142
+ });
143
+ });
@@ -1,4 +1,7 @@
1
- interface MediaProbeResult {
1
+ import { resolveMediaPreviewUrl } from "../components/thumbnailUtils";
2
+ import { TIMELINE_VIEWPORT_BUDGETS } from "./timelineViewportBudgets";
3
+
4
+ export interface MediaProbeResult {
2
5
  duration: number;
3
6
  width?: number;
4
7
  height?: number;
@@ -6,11 +9,24 @@ interface MediaProbeResult {
6
9
  hasAudio: boolean;
7
10
  }
8
11
 
9
- const cache = new Map<string, MediaProbeResult>();
12
+ interface CachedProbe {
13
+ result: MediaProbeResult;
14
+ lastAccess: number;
15
+ }
16
+
17
+ const cache = new Map<string, CachedProbe>();
10
18
  const inflight = new Map<string, Promise<MediaProbeResult | null>>();
11
19
  // URLs whose probe failed (CORS, 404, non-media). Remembered so the rAF-driven
12
20
  // timeline re-derive doesn't re-fetch them every frame and flood the console.
13
- const failed = new Set<string>();
21
+ const failed = new Map<string, { failedAt: number; lastAccess: number }>();
22
+ let accessSequence = 0;
23
+ let activeProbes = 0;
24
+ let registryEpoch = 0;
25
+ const probeQueue: Array<{
26
+ key: string;
27
+ epoch: number;
28
+ resolve: (result: MediaProbeResult | null) => void;
29
+ }> = [];
14
30
 
15
31
  let mediabunnyModule: typeof import("mediabunny") | null | false = null;
16
32
 
@@ -65,7 +81,26 @@ async function probeOne(url: string): Promise<MediaProbeResult | null> {
65
81
  }
66
82
 
67
83
  function getCachedProbe(url: string): MediaProbeResult | undefined {
68
- return cache.get(normalizeUrl(url));
84
+ const cached = cache.get(normalizeUrl(url));
85
+ if (cached) cached.lastAccess = ++accessSequence;
86
+ return cached?.result;
87
+ }
88
+
89
+ function resolveProbeSource(src: string, projectId: string | null): string {
90
+ return projectId ? resolveMediaPreviewUrl(src, projectId, window.location.origin) : src;
91
+ }
92
+
93
+ function evictMetadataOverflow(): void {
94
+ const overflow = cache.size + failed.size - TIMELINE_VIEWPORT_BUDGETS.metadataRegistryEntries;
95
+ if (overflow <= 0) return;
96
+ const entries = [
97
+ ...Array.from(cache, ([key, value]) => ({ key, at: value.lastAccess, failed: false })),
98
+ ...Array.from(failed, ([key, value]) => ({ key, at: value.lastAccess, failed: true })),
99
+ ].sort((left, right) => left.at - right.at);
100
+ for (const entry of entries.slice(0, overflow)) {
101
+ if (entry.failed) failed.delete(entry.key);
102
+ else cache.delete(entry.key);
103
+ }
69
104
  }
70
105
 
71
106
  /**
@@ -76,11 +111,11 @@ function getCachedProbe(url: string): MediaProbeResult | undefined {
76
111
  */
77
112
  export function applyCachedSourceDurations<
78
113
  T extends { src?: string; tag: string; sourceDuration?: number },
79
- >(elements: T[]): T[] {
114
+ >(elements: T[], projectId: string | null): T[] {
80
115
  return elements.map((el) => {
81
116
  const tag = el.tag.toLowerCase();
82
117
  if (!el.src || el.sourceDuration != null || (tag !== "audio" && tag !== "video")) return el;
83
- const cached = getCachedProbe(el.src);
118
+ const cached = getCachedProbe(resolveProbeSource(el.src, projectId));
84
119
  return cached?.duration && cached.duration > 0
85
120
  ? { ...el, sourceDuration: cached.duration }
86
121
  : el;
@@ -94,39 +129,96 @@ export function applyCachedSourceDurations<
94
129
  */
95
130
  export async function probeMissingSourceDurations<
96
131
  T extends { src?: string; tag: string; sourceDuration?: number; key?: string; id: string },
97
- >(elements: T[], apply: (key: string, durationSeconds: number) => void): Promise<void> {
98
- const needs = elements.filter(
99
- (el) =>
100
- el.src &&
101
- el.sourceDuration == null &&
102
- ["video", "audio"].includes(el.tag.toLowerCase()) &&
103
- !getCachedProbe(el.src) &&
104
- !failed.has(normalizeUrl(el.src)),
105
- );
132
+ >(
133
+ elements: T[],
134
+ projectId: string | null,
135
+ apply: (key: string, durationSeconds: number) => void,
136
+ ): Promise<void> {
137
+ const needs = elements.flatMap((el) => {
138
+ if (
139
+ !el.src ||
140
+ el.sourceDuration != null ||
141
+ !["video", "audio"].includes(el.tag.toLowerCase())
142
+ ) {
143
+ return [];
144
+ }
145
+ const source = resolveProbeSource(el.src, projectId);
146
+ return !getCachedProbe(source) && !hasFreshFailure(normalizeUrl(source))
147
+ ? [{ el, source }]
148
+ : [];
149
+ });
106
150
  if (needs.length === 0) return;
107
151
  await Promise.allSettled(
108
- needs.map(async (el) => {
109
- const result = await probeMediaUrl(el.src!);
152
+ needs.map(async ({ el, source }) => {
153
+ const result = await probeMediaUrl(source);
110
154
  if (result) apply(el.key ?? el.id, result.duration);
111
155
  }),
112
156
  );
113
157
  }
114
158
 
115
- async function probeMediaUrl(url: string): Promise<MediaProbeResult | null> {
159
+ function hasFreshFailure(key: string): boolean {
160
+ const failedAt = failed.get(key);
161
+ if (failedAt === undefined) return false;
162
+ if (Date.now() - failedAt.failedAt < TIMELINE_VIEWPORT_BUDGETS.metadataFailureTtlMs) {
163
+ failedAt.lastAccess = ++accessSequence;
164
+ return true;
165
+ }
166
+ failed.delete(key);
167
+ return false;
168
+ }
169
+
170
+ export async function probeMediaUrl(url: string): Promise<MediaProbeResult | null> {
116
171
  const key = normalizeUrl(url);
117
- const cached = cache.get(key);
172
+ const cached = getCachedProbe(key);
118
173
  if (cached) return cached;
119
- if (failed.has(key)) return null;
174
+ if (hasFreshFailure(key)) return null;
120
175
 
121
176
  let pending = inflight.get(key);
122
177
  if (pending) return pending;
123
178
 
124
- pending = probeOne(key).then((result) => {
125
- inflight.delete(key);
126
- if (result) cache.set(key, result);
127
- else failed.add(key);
128
- return result;
179
+ pending = new Promise<MediaProbeResult | null>((resolve) => {
180
+ probeQueue.push({ key, epoch: registryEpoch, resolve });
181
+ pumpProbeQueue();
129
182
  });
130
183
  inflight.set(key, pending);
131
184
  return pending;
132
185
  }
186
+
187
+ function pumpProbeQueue(): void {
188
+ while (activeProbes < TIMELINE_VIEWPORT_BUDGETS.concurrentMetadataJobs) {
189
+ const queued = probeQueue.shift();
190
+ if (!queued) return;
191
+ if (queued.epoch !== registryEpoch) {
192
+ queued.resolve(null);
193
+ continue;
194
+ }
195
+ activeProbes++;
196
+ void probeOne(queued.key)
197
+ .then((result) => {
198
+ if (queued.epoch !== registryEpoch) return null;
199
+ inflight.delete(queued.key);
200
+ if (result) cache.set(queued.key, { result, lastAccess: ++accessSequence });
201
+ else failed.set(queued.key, { failedAt: Date.now(), lastAccess: ++accessSequence });
202
+ evictMetadataOverflow();
203
+ return result;
204
+ })
205
+ .then(queued.resolve)
206
+ .finally(() => {
207
+ activeProbes--;
208
+ pumpProbeQueue();
209
+ });
210
+ }
211
+ }
212
+
213
+ export function getMediaProbeDiagnostics() {
214
+ return { cached: cache.size, failed: failed.size, inflight: inflight.size };
215
+ }
216
+
217
+ export function resetMediaProbeRegistry(): void {
218
+ registryEpoch++;
219
+ for (const queued of probeQueue.splice(0)) queued.resolve(null);
220
+ cache.clear();
221
+ failed.clear();
222
+ inflight.clear();
223
+ accessSequence = 0;
224
+ }
@@ -0,0 +1,14 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { defaultThumbnailMode, effectiveThumbnailMode } from "./thumbnailPolicy";
3
+
4
+ describe("thumbnail runtime policy", () => {
5
+ it("defaults missing preferences adaptively after activation", () => {
6
+ expect(defaultThumbnailMode(undefined, "follow-preference")).toBe("adaptive");
7
+ expect(defaultThumbnailMode(undefined, "legacy-default")).toBe("hidden");
8
+ });
9
+
10
+ it("forces the safe renderer without overwriting user intent", () => {
11
+ expect(effectiveThumbnailMode("adaptive", "force-hidden")).toBe("hidden");
12
+ expect(effectiveThumbnailMode("adaptive", "follow-preference")).toBe("adaptive");
13
+ });
14
+ });
@@ -0,0 +1,24 @@
1
+ export type ThumbnailMode = "adaptive" | "hidden";
2
+ export type ThumbnailRuntimePolicy = "follow-preference" | "force-hidden" | "legacy-default";
3
+
4
+ // "Adaptive" currently means the scheduler pauses rich work while scrolling.
5
+ // The mode name leaves room for finer-grained runtime heuristics later.
6
+
7
+ const rawPolicy = import.meta.env.VITE_STUDIO_TIMELINE_THUMBNAIL_POLICY;
8
+
9
+ const studioThumbnailRuntimePolicy: ThumbnailRuntimePolicy =
10
+ rawPolicy === "force-hidden" || rawPolicy === "legacy-default" ? rawPolicy : "follow-preference";
11
+
12
+ export function defaultThumbnailMode(
13
+ storedMode: ThumbnailMode | undefined,
14
+ policy: ThumbnailRuntimePolicy = studioThumbnailRuntimePolicy,
15
+ ): ThumbnailMode {
16
+ return storedMode ?? (policy === "legacy-default" ? "hidden" : "adaptive");
17
+ }
18
+
19
+ export function effectiveThumbnailMode(
20
+ preferredMode: ThumbnailMode,
21
+ policy: ThumbnailRuntimePolicy = studioThumbnailRuntimePolicy,
22
+ ): ThumbnailMode {
23
+ return policy === "force-hidden" ? "hidden" : preferredMode;
24
+ }