@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.
- package/dist/assets/{hyperframes-player-NSdcoOcK.js → hyperframes-player-C1o3TZ1w.js} +1 -1
- package/dist/assets/{index-Dp1zVb9X.js → index-4KcC7fDo.js} +195 -195
- package/dist/assets/{index-wS6f7F5q.js → index-Bl6x228Z.js} +1 -1
- package/dist/assets/{index-CCGnc_0M.js → index-DR4Dsw8D.js} +1 -1
- package/dist/assets/index-tBPidglp.css +1 -0
- package/dist/index.d.ts +27 -7
- package/dist/index.html +2 -2
- package/dist/index.js +2644 -1837
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/EditorShell.selectionSync.test.tsx +109 -0
- package/src/components/EditorShell.tsx +31 -5
- package/src/components/TimelineToolbar.test.tsx +20 -1
- package/src/components/TimelineToolbar.tsx +29 -1
- package/src/hooks/useRenderClipContent.test.ts +77 -2
- package/src/hooks/useRenderClipContent.ts +78 -14
- package/src/hooks/useThumbnailLease.test.tsx +150 -0
- package/src/hooks/useThumbnailLease.ts +44 -0
- package/src/hooks/useTimelineSelectionPreviewSync.test.tsx +79 -5
- package/src/hooks/useTimelineSelectionPreviewSync.ts +40 -13
- package/src/player/components/AudioWaveform.test.tsx +53 -0
- package/src/player/components/AudioWaveform.tsx +137 -140
- package/src/player/components/CompositionThumbnail.test.ts +33 -10
- package/src/player/components/CompositionThumbnail.tsx +78 -61
- package/src/player/components/ImageThumbnail.test.tsx +26 -14
- package/src/player/components/ImageThumbnail.tsx +58 -101
- package/src/player/components/Timeline.tsx +20 -19
- package/src/player/components/TimelineGestureOverlay.tsx +3 -0
- package/src/player/components/TimelineLanes.test.tsx +24 -2
- package/src/player/components/TimelineLanes.tsx +12 -12
- package/src/player/components/TimelineTypes.ts +6 -0
- package/src/player/components/VideoThumbnail.test.tsx +46 -100
- package/src/player/components/VideoThumbnail.tsx +74 -156
- package/src/player/components/thumbnailUtils.test.ts +35 -0
- package/src/player/components/thumbnailUtils.ts +86 -8
- package/src/player/components/timelineClipChildren.test.ts +31 -0
- package/src/player/components/timelineClipChildren.tsx +21 -2
- package/src/player/components/timelineLaneProps.ts +3 -0
- package/src/player/components/useTimelineClipRenderWindow.ts +9 -2
- package/src/player/hooks/useTimelinePlayer.ts +14 -9
- package/src/player/lib/mediaProbe.test.ts +143 -0
- package/src/player/lib/mediaProbe.ts +117 -25
- package/src/player/lib/thumbnailPolicy.test.ts +14 -0
- package/src/player/lib/thumbnailPolicy.ts +24 -0
- package/src/player/lib/thumbnailScheduler.test.ts +388 -0
- package/src/player/lib/thumbnailScheduler.ts +483 -0
- package/src/player/lib/thumbnailVideoDecoder.test.ts +127 -0
- package/src/player/lib/thumbnailVideoDecoder.ts +170 -0
- package/src/player/lib/timelineViewportBudgets.ts +2 -0
- package/src/player/store/playerStore.ts +3 -1
- package/src/player/store/thumbnailSlice.ts +18 -0
- package/src/utils/frameCapture.test.ts +1 -1
- package/src/utils/frameCapture.ts +1 -0
- package/src/utils/projectRouting.test.ts +1 -1
- package/src/utils/studioSelectionSnapshot.test.ts +1 -1
- package/src/utils/studioSelectionSnapshot.ts +1 -0
- package/src/utils/studioUiPreferences.test.ts +14 -0
- package/src/utils/studioUiPreferences.ts +6 -0
- package/dist/assets/index-D78KEjgB.css +0 -1
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { resolveTimelineViewportBudgets } from "./timelineViewportBudgets";
|
|
3
|
+
import {
|
|
4
|
+
createThumbnailKey,
|
|
5
|
+
createThumbnailRequestIdentity,
|
|
6
|
+
ThumbnailScheduler,
|
|
7
|
+
type ThumbnailLoadedResult,
|
|
8
|
+
type ThumbnailPriority,
|
|
9
|
+
type ThumbnailRequest,
|
|
10
|
+
} from "./thumbnailScheduler";
|
|
11
|
+
|
|
12
|
+
function deferred<T>() {
|
|
13
|
+
let resolve!: (value: T) => void;
|
|
14
|
+
let reject!: (reason?: unknown) => void;
|
|
15
|
+
const promise = new Promise<T>((accept, fail) => {
|
|
16
|
+
resolve = accept;
|
|
17
|
+
reject = fail;
|
|
18
|
+
});
|
|
19
|
+
return { promise, resolve, reject };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function result(name: string, weight = 1, dispose = vi.fn()): ThumbnailLoadedResult {
|
|
23
|
+
return { value: { kind: "image", url: name, aspect: 1 }, weight, dispose };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function request(
|
|
27
|
+
key: string,
|
|
28
|
+
load: ThumbnailRequest["load"],
|
|
29
|
+
priority: ThumbnailPriority = "visible",
|
|
30
|
+
overrides: Partial<ThumbnailRequest> = {},
|
|
31
|
+
): ThumbnailRequest {
|
|
32
|
+
return {
|
|
33
|
+
key,
|
|
34
|
+
projectId: "project-a",
|
|
35
|
+
sessionEpoch: 1,
|
|
36
|
+
kind: "image",
|
|
37
|
+
priority,
|
|
38
|
+
load,
|
|
39
|
+
...overrides,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function flush(): Promise<void> {
|
|
44
|
+
await Promise.resolve();
|
|
45
|
+
await Promise.resolve();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe("ThumbnailScheduler", () => {
|
|
49
|
+
it("deduplicates identical requests and releases subscribers independently", async () => {
|
|
50
|
+
const scheduler = new ThumbnailScheduler();
|
|
51
|
+
const pending = deferred<ThumbnailLoadedResult>();
|
|
52
|
+
const load = vi.fn(() => pending.promise);
|
|
53
|
+
const listenerA = vi.fn();
|
|
54
|
+
const listenerB = vi.fn();
|
|
55
|
+
|
|
56
|
+
const leaseA = scheduler.acquire(request("same", load), listenerA);
|
|
57
|
+
const leaseB = scheduler.acquire(request("same", load), listenerB);
|
|
58
|
+
expect(load).toHaveBeenCalledTimes(1);
|
|
59
|
+
|
|
60
|
+
leaseA.release();
|
|
61
|
+
listenerA.mockClear();
|
|
62
|
+
pending.resolve(result("poster"));
|
|
63
|
+
await flush();
|
|
64
|
+
expect(scheduler.getSnapshot(request("same", load))).toMatchObject({
|
|
65
|
+
status: "ready",
|
|
66
|
+
value: { url: "poster" },
|
|
67
|
+
});
|
|
68
|
+
expect(listenerA).not.toHaveBeenCalled();
|
|
69
|
+
expect(listenerB).toHaveBeenCalled();
|
|
70
|
+
|
|
71
|
+
leaseB.release();
|
|
72
|
+
expect(scheduler.getDiagnostics().leases).toBe(0);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("keeps snapshots referentially stable and supports independent leases sharing a callback", async () => {
|
|
76
|
+
const scheduler = new ThumbnailScheduler();
|
|
77
|
+
const listener = vi.fn();
|
|
78
|
+
const shared = request("stable", async () => result("stable"));
|
|
79
|
+
const first = scheduler.acquire(shared, listener);
|
|
80
|
+
const second = scheduler.acquire(shared, listener);
|
|
81
|
+
await flush();
|
|
82
|
+
expect(scheduler.getSnapshot(shared)).toBe(scheduler.getSnapshot(shared));
|
|
83
|
+
listener.mockClear();
|
|
84
|
+
scheduler.invalidateProject("project-a");
|
|
85
|
+
expect(listener).not.toHaveBeenCalled();
|
|
86
|
+
expect(scheduler.getDiagnostics().leases).toBe(2);
|
|
87
|
+
first.release();
|
|
88
|
+
second.release();
|
|
89
|
+
scheduler.invalidateProject("project-a");
|
|
90
|
+
expect(scheduler.getSnapshot(shared).status).toBe("idle");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("scopes the same caller key by work shape", async () => {
|
|
94
|
+
const scheduler = new ThumbnailScheduler();
|
|
95
|
+
const poster = request("shared", async () => result("poster"));
|
|
96
|
+
const strip = request("shared", async () => result("strip"), "visible", {
|
|
97
|
+
kind: "video",
|
|
98
|
+
rich: true,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
scheduler.acquire(poster, vi.fn());
|
|
102
|
+
scheduler.acquire(strip, vi.fn());
|
|
103
|
+
await flush();
|
|
104
|
+
|
|
105
|
+
expect(scheduler.getSnapshot(poster)).toMatchObject({
|
|
106
|
+
status: "ready",
|
|
107
|
+
value: { url: "poster" },
|
|
108
|
+
});
|
|
109
|
+
expect(scheduler.getSnapshot(strip)).toMatchObject({
|
|
110
|
+
status: "ready",
|
|
111
|
+
value: { url: "strip" },
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("orders interaction before visible before overscan when a slot opens", async () => {
|
|
116
|
+
const scheduler = new ThumbnailScheduler(
|
|
117
|
+
resolveTimelineViewportBudgets({ concurrentMetadataJobs: 1 }),
|
|
118
|
+
);
|
|
119
|
+
const first = deferred<ThumbnailLoadedResult>();
|
|
120
|
+
const starts: string[] = [];
|
|
121
|
+
const load = (name: string, pending?: ReturnType<typeof deferred<ThumbnailLoadedResult>>) =>
|
|
122
|
+
vi.fn(() => {
|
|
123
|
+
starts.push(name);
|
|
124
|
+
return pending?.promise ?? Promise.resolve(result(name));
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
scheduler.acquire(request("first", load("first", first)), vi.fn());
|
|
128
|
+
scheduler.acquire(request("overscan", load("overscan"), "overscan"), vi.fn());
|
|
129
|
+
scheduler.acquire(request("visible", load("visible"), "visible"), vi.fn());
|
|
130
|
+
scheduler.acquire(request("interaction", load("interaction"), "interaction"), vi.fn());
|
|
131
|
+
expect(starts).toEqual(["first"]);
|
|
132
|
+
|
|
133
|
+
first.resolve(result("first"));
|
|
134
|
+
await vi.waitFor(() => {
|
|
135
|
+
expect(starts).toEqual(["first", "interaction", "visible", "overscan"]);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("suspends new rich work while scrolling but lets posters proceed", async () => {
|
|
140
|
+
const scheduler = new ThumbnailScheduler();
|
|
141
|
+
const richLoad = vi.fn(async () => result("rich"));
|
|
142
|
+
const posterLoad = vi.fn(async () => result("poster"));
|
|
143
|
+
scheduler.setScrolling(true);
|
|
144
|
+
|
|
145
|
+
scheduler.acquire(request("rich", richLoad, "interaction", { rich: true }), vi.fn());
|
|
146
|
+
scheduler.acquire(request("poster", posterLoad), vi.fn());
|
|
147
|
+
await flush();
|
|
148
|
+
expect(richLoad).not.toHaveBeenCalled();
|
|
149
|
+
expect(posterLoad).toHaveBeenCalledTimes(1);
|
|
150
|
+
|
|
151
|
+
scheduler.setScrolling(false);
|
|
152
|
+
await flush();
|
|
153
|
+
expect(richLoad).toHaveBeenCalledTimes(1);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("aborts queued and active jobs after the final release", async () => {
|
|
157
|
+
const scheduler = new ThumbnailScheduler(
|
|
158
|
+
resolveTimelineViewportBudgets({ concurrentVideoDecodes: 1 }),
|
|
159
|
+
);
|
|
160
|
+
const active = deferred<ThumbnailLoadedResult>();
|
|
161
|
+
let activeSignal: AbortSignal | undefined;
|
|
162
|
+
const activeLease = scheduler.acquire(
|
|
163
|
+
request(
|
|
164
|
+
"active",
|
|
165
|
+
(signal) => {
|
|
166
|
+
activeSignal = signal;
|
|
167
|
+
return active.promise;
|
|
168
|
+
},
|
|
169
|
+
"visible",
|
|
170
|
+
{ kind: "video" },
|
|
171
|
+
),
|
|
172
|
+
vi.fn(),
|
|
173
|
+
);
|
|
174
|
+
const queuedLoad = vi.fn(async () => result("queued"));
|
|
175
|
+
const queuedLease = scheduler.acquire(
|
|
176
|
+
request("queued", queuedLoad, "visible", { kind: "video" }),
|
|
177
|
+
vi.fn(),
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
queuedLease.release();
|
|
181
|
+
activeLease.release();
|
|
182
|
+
expect(activeSignal?.aborted).toBe(true);
|
|
183
|
+
expect(queuedLoad).not.toHaveBeenCalled();
|
|
184
|
+
active.reject(new DOMException("aborted", "AbortError"));
|
|
185
|
+
await flush();
|
|
186
|
+
expect(scheduler.getDiagnostics()).toMatchObject({ queued: 0, active: 0, leases: 0 });
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("disposes a late result exactly once after its final lease releases", async () => {
|
|
190
|
+
const scheduler = new ThumbnailScheduler();
|
|
191
|
+
const pending = deferred<ThumbnailLoadedResult>();
|
|
192
|
+
const dispose = vi.fn();
|
|
193
|
+
const lease = scheduler.acquire(
|
|
194
|
+
request("late", () => pending.promise),
|
|
195
|
+
vi.fn(),
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
lease.release();
|
|
199
|
+
pending.resolve(result("late", 1, dispose));
|
|
200
|
+
await flush();
|
|
201
|
+
expect(dispose).toHaveBeenCalledTimes(1);
|
|
202
|
+
expect(scheduler.getSnapshot(request("late", () => pending.promise))).toEqual({
|
|
203
|
+
status: "idle",
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("preserves a synchronous re-acquire when an expired failure is replaced", async () => {
|
|
208
|
+
vi.useFakeTimers();
|
|
209
|
+
const scheduler = new ThumbnailScheduler(
|
|
210
|
+
resolveTimelineViewportBudgets({ metadataFailureTtlMs: 10 }),
|
|
211
|
+
);
|
|
212
|
+
const load = vi
|
|
213
|
+
.fn<ThumbnailRequest["load"]>()
|
|
214
|
+
.mockRejectedValueOnce(new Error("temporary"))
|
|
215
|
+
.mockResolvedValue(result("recovered"));
|
|
216
|
+
const failed = request("retry", load);
|
|
217
|
+
let reacquireOnNotify = false;
|
|
218
|
+
let nestedLease: ReturnType<ThumbnailScheduler["acquire"]> | undefined;
|
|
219
|
+
const firstLease = scheduler.acquire(failed, () => {
|
|
220
|
+
if (!reacquireOnNotify) return;
|
|
221
|
+
reacquireOnNotify = false;
|
|
222
|
+
nestedLease = scheduler.acquire(failed, vi.fn());
|
|
223
|
+
});
|
|
224
|
+
await flush();
|
|
225
|
+
vi.advanceTimersByTime(11);
|
|
226
|
+
reacquireOnNotify = true;
|
|
227
|
+
|
|
228
|
+
const outerLease = scheduler.acquire(failed, vi.fn());
|
|
229
|
+
await flush();
|
|
230
|
+
|
|
231
|
+
expect(load).toHaveBeenCalledTimes(2);
|
|
232
|
+
expect(scheduler.getSnapshot(failed)).toMatchObject({
|
|
233
|
+
status: "ready",
|
|
234
|
+
value: { url: "recovered" },
|
|
235
|
+
});
|
|
236
|
+
expect(scheduler.getDiagnostics().leases).toBe(2);
|
|
237
|
+
firstLease.release();
|
|
238
|
+
nestedLease?.release();
|
|
239
|
+
outerLease.release();
|
|
240
|
+
vi.useRealTimers();
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("times out a hung loader, frees its bucket, and disposes a late result", async () => {
|
|
244
|
+
vi.useFakeTimers();
|
|
245
|
+
const scheduler = new ThumbnailScheduler(
|
|
246
|
+
resolveTimelineViewportBudgets({
|
|
247
|
+
concurrentVideoDecodes: 1,
|
|
248
|
+
thumbnailLoadTimeoutMs: 10,
|
|
249
|
+
}),
|
|
250
|
+
);
|
|
251
|
+
const hung = deferred<ThumbnailLoadedResult>();
|
|
252
|
+
const dispose = vi.fn();
|
|
253
|
+
let signal: AbortSignal | undefined;
|
|
254
|
+
const hungRequest = request(
|
|
255
|
+
"hung",
|
|
256
|
+
(activeSignal) => {
|
|
257
|
+
signal = activeSignal;
|
|
258
|
+
return hung.promise;
|
|
259
|
+
},
|
|
260
|
+
"visible",
|
|
261
|
+
{ kind: "video" },
|
|
262
|
+
);
|
|
263
|
+
const nextLoad = vi.fn(async () => result("next"));
|
|
264
|
+
const nextRequest = request("next", nextLoad, "visible", { kind: "video" });
|
|
265
|
+
const hungLease = scheduler.acquire(hungRequest, vi.fn());
|
|
266
|
+
const nextLease = scheduler.acquire(nextRequest, vi.fn());
|
|
267
|
+
|
|
268
|
+
await vi.advanceTimersByTimeAsync(11);
|
|
269
|
+
await flush();
|
|
270
|
+
expect(signal?.aborted).toBe(true);
|
|
271
|
+
expect(nextLoad).toHaveBeenCalledTimes(1);
|
|
272
|
+
expect(scheduler.getSnapshot(hungRequest).status).toBe("error");
|
|
273
|
+
|
|
274
|
+
hung.resolve(result("late", 1, dispose));
|
|
275
|
+
await flush();
|
|
276
|
+
expect(dispose).toHaveBeenCalledTimes(1);
|
|
277
|
+
hungLease.release();
|
|
278
|
+
nextLease.release();
|
|
279
|
+
vi.useRealTimers();
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("isolates late results by session epoch", async () => {
|
|
283
|
+
const scheduler = new ThumbnailScheduler();
|
|
284
|
+
const old = deferred<ThumbnailLoadedResult>();
|
|
285
|
+
const oldRequest = request("poster", () => old.promise, "visible", { sessionEpoch: 1 });
|
|
286
|
+
const nextRequest = request("poster", async () => result("next"), "visible", {
|
|
287
|
+
sessionEpoch: 2,
|
|
288
|
+
});
|
|
289
|
+
scheduler.acquire(oldRequest, vi.fn());
|
|
290
|
+
scheduler.acquire(nextRequest, vi.fn());
|
|
291
|
+
await flush();
|
|
292
|
+
old.resolve(result("old"));
|
|
293
|
+
await flush();
|
|
294
|
+
expect(scheduler.getSnapshot(nextRequest)).toMatchObject({
|
|
295
|
+
status: "ready",
|
|
296
|
+
value: { url: "next" },
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("turns synchronous loader errors into bounded failure snapshots", async () => {
|
|
301
|
+
const scheduler = new ThumbnailScheduler();
|
|
302
|
+
const bad = request("throws", () => {
|
|
303
|
+
throw new Error("sync failure");
|
|
304
|
+
});
|
|
305
|
+
const lease = scheduler.acquire(bad, vi.fn());
|
|
306
|
+
await flush();
|
|
307
|
+
expect(scheduler.getSnapshot(bad)).toMatchObject({ status: "error" });
|
|
308
|
+
expect(scheduler.getDiagnostics().active).toBe(0);
|
|
309
|
+
lease.release();
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("disposes uncached results after the final lease and tolerates broken disposers", async () => {
|
|
313
|
+
const scheduler = new ThumbnailScheduler(
|
|
314
|
+
resolveTimelineViewportBudgets({ thumbnailCacheBytes: 1 }),
|
|
315
|
+
);
|
|
316
|
+
const dispose = vi.fn(() => {
|
|
317
|
+
throw new Error("cleanup failure");
|
|
318
|
+
});
|
|
319
|
+
const oversized = request("oversized", async () => result("large", 2, dispose));
|
|
320
|
+
const lease = scheduler.acquire(oversized, vi.fn());
|
|
321
|
+
await flush();
|
|
322
|
+
expect(scheduler.getSnapshot(oversized).status).toBe("ready");
|
|
323
|
+
expect(() => lease.release()).not.toThrow();
|
|
324
|
+
expect(dispose).toHaveBeenCalledTimes(1);
|
|
325
|
+
expect(scheduler.getSnapshot(oversized).status).toBe("idle");
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("evicts least-recently-used unleased values by bytes, count, and project count", async () => {
|
|
329
|
+
const scheduler = new ThumbnailScheduler(
|
|
330
|
+
resolveTimelineViewportBudgets({
|
|
331
|
+
thumbnailCacheBytes: 6,
|
|
332
|
+
thumbnailCacheEntries: 2,
|
|
333
|
+
thumbnailCacheEntriesPerProject: 2,
|
|
334
|
+
}),
|
|
335
|
+
);
|
|
336
|
+
const disposals = [vi.fn(), vi.fn(), vi.fn()];
|
|
337
|
+
for (let index = 0; index < 3; index++) {
|
|
338
|
+
const lease = scheduler.acquire(
|
|
339
|
+
request(`key-${index}`, async () => result(`url-${index}`, 3, disposals[index])),
|
|
340
|
+
vi.fn(),
|
|
341
|
+
);
|
|
342
|
+
await flush();
|
|
343
|
+
lease.release();
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
expect(scheduler.getDiagnostics()).toMatchObject({ cacheEntries: 2, cacheBytes: 6 });
|
|
347
|
+
expect(disposals[0]).toHaveBeenCalledTimes(1);
|
|
348
|
+
expect(disposals[1]).not.toHaveBeenCalled();
|
|
349
|
+
expect(disposals[2]).not.toHaveBeenCalled();
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it("keeps failed requests quiet until the TTL expires", async () => {
|
|
353
|
+
vi.useFakeTimers();
|
|
354
|
+
const scheduler = new ThumbnailScheduler(
|
|
355
|
+
resolveTimelineViewportBudgets({ metadataFailureTtlMs: 100 }),
|
|
356
|
+
);
|
|
357
|
+
const load = vi.fn(async () => {
|
|
358
|
+
throw new Error("unsupported");
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
const first = scheduler.acquire(request("failed", load), vi.fn());
|
|
362
|
+
await flush();
|
|
363
|
+
first.release();
|
|
364
|
+
const second = scheduler.acquire(request("failed", load), vi.fn());
|
|
365
|
+
expect(load).toHaveBeenCalledTimes(1);
|
|
366
|
+
second.release();
|
|
367
|
+
|
|
368
|
+
vi.advanceTimersByTime(101);
|
|
369
|
+
scheduler.acquire(request("failed", load), vi.fn());
|
|
370
|
+
await flush();
|
|
371
|
+
expect(load).toHaveBeenCalledTimes(2);
|
|
372
|
+
vi.useRealTimers();
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
describe("createThumbnailKey", () => {
|
|
377
|
+
it("normalizes field order and preserves sentinel zero values", () => {
|
|
378
|
+
expect(createThumbnailKey({ source: "clip a", time: 0, missing: undefined })).toBe(
|
|
379
|
+
createThumbnailKey({ time: 0, source: "clip a" }),
|
|
380
|
+
);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it("includes the work shape in request identity", () => {
|
|
384
|
+
const poster = request("shared", vi.fn(), "visible", { kind: "video" });
|
|
385
|
+
const strip = { ...poster, rich: true };
|
|
386
|
+
expect(createThumbnailRequestIdentity(poster)).not.toBe(createThumbnailRequestIdentity(strip));
|
|
387
|
+
});
|
|
388
|
+
});
|