@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,483 @@
|
|
|
1
|
+
import { TIMELINE_VIEWPORT_BUDGETS, type TimelineViewportBudgets } from "./timelineViewportBudgets";
|
|
2
|
+
|
|
3
|
+
export type ThumbnailPriority = "overscan" | "visible" | "interaction";
|
|
4
|
+
export type ThumbnailJobKind = "video" | "image" | "composition" | "waveform";
|
|
5
|
+
|
|
6
|
+
export type ThumbnailValue =
|
|
7
|
+
| { kind: "image"; url: string; aspect: number }
|
|
8
|
+
| { kind: "filmstrip"; urls: readonly string[]; aspect: number }
|
|
9
|
+
| { kind: "waveform"; peaks: readonly number[] };
|
|
10
|
+
|
|
11
|
+
export interface ThumbnailLoadedResult {
|
|
12
|
+
value: ThumbnailValue;
|
|
13
|
+
/** Estimated decoded/object-URL bytes retained by this result. */
|
|
14
|
+
weight: number;
|
|
15
|
+
dispose?: () => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ThumbnailRequest {
|
|
19
|
+
key: string;
|
|
20
|
+
projectId: string;
|
|
21
|
+
sessionEpoch: number;
|
|
22
|
+
kind: ThumbnailJobKind;
|
|
23
|
+
priority: ThumbnailPriority;
|
|
24
|
+
/** Rich work is paused while the timeline is fast-scrolling. */
|
|
25
|
+
rich?: boolean;
|
|
26
|
+
load: (signal: AbortSignal) => Promise<ThumbnailLoadedResult>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type ThumbnailSnapshot =
|
|
30
|
+
| { status: "idle" | "queued" | "loading" }
|
|
31
|
+
| { status: "ready"; value: ThumbnailValue }
|
|
32
|
+
| { status: "error"; error: Error };
|
|
33
|
+
|
|
34
|
+
export interface ThumbnailLease {
|
|
35
|
+
updatePriority(priority: ThumbnailPriority): void;
|
|
36
|
+
release(): void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ThumbnailSchedulerDiagnostics {
|
|
40
|
+
queued: number;
|
|
41
|
+
active: number;
|
|
42
|
+
leases: number;
|
|
43
|
+
cacheEntries: number;
|
|
44
|
+
cacheBytes: number;
|
|
45
|
+
waveformCacheEntries: number;
|
|
46
|
+
waveformCacheBytes: number;
|
|
47
|
+
activeByKind: Readonly<Record<ThumbnailJobKind, number>>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
type EntryState = "queued" | "loading" | "ready" | "error";
|
|
51
|
+
|
|
52
|
+
interface ThumbnailEntry {
|
|
53
|
+
request: ThumbnailRequest;
|
|
54
|
+
scopedKey: string;
|
|
55
|
+
state: EntryState;
|
|
56
|
+
leases: Map<number, ThumbnailPriority>;
|
|
57
|
+
listeners: Map<number, () => void>;
|
|
58
|
+
controller: AbortController | null;
|
|
59
|
+
value?: ThumbnailValue;
|
|
60
|
+
error?: Error;
|
|
61
|
+
failedAt?: number;
|
|
62
|
+
weight: number;
|
|
63
|
+
dispose?: () => void;
|
|
64
|
+
disposed: boolean;
|
|
65
|
+
cached: boolean;
|
|
66
|
+
lastAccess: number;
|
|
67
|
+
snapshot: ThumbnailSnapshot;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const PRIORITY_SCORE: Readonly<Record<ThumbnailPriority, number>> = {
|
|
71
|
+
overscan: 0,
|
|
72
|
+
visible: 1,
|
|
73
|
+
interaction: 2,
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const EMPTY_SNAPSHOT: ThumbnailSnapshot = Object.freeze({ status: "idle" });
|
|
77
|
+
|
|
78
|
+
function concurrencyBucket(kind: ThumbnailJobKind): "video" | "composition" | "general" {
|
|
79
|
+
if (kind === "video") return "video";
|
|
80
|
+
if (kind === "composition") return "composition";
|
|
81
|
+
return "general";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function errorFrom(reason: unknown): Error {
|
|
85
|
+
return reason instanceof Error ? reason : new Error(String(reason));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function createThumbnailKey(parts: Readonly<Record<string, string | number | undefined>>) {
|
|
89
|
+
return Object.entries(parts)
|
|
90
|
+
.filter((entry): entry is [string, string | number] => entry[1] !== undefined)
|
|
91
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
92
|
+
.map(([name, value]) => `${encodeURIComponent(name)}=${encodeURIComponent(String(value))}`)
|
|
93
|
+
.join("&");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function createThumbnailRequestIdentity(
|
|
97
|
+
request: Pick<ThumbnailRequest, "key" | "projectId" | "sessionEpoch" | "kind" | "rich">,
|
|
98
|
+
) {
|
|
99
|
+
return createThumbnailKey({
|
|
100
|
+
project: request.projectId,
|
|
101
|
+
session: request.sessionEpoch,
|
|
102
|
+
request: request.key,
|
|
103
|
+
kind: request.kind,
|
|
104
|
+
rich: request.rich ? 1 : 0,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Sole client owner for thumbnail work, cached resources, and cleanup. */
|
|
109
|
+
export class ThumbnailScheduler {
|
|
110
|
+
private readonly entries = new Map<string, ThumbnailEntry>();
|
|
111
|
+
private readonly budgets: Readonly<TimelineViewportBudgets>;
|
|
112
|
+
private nextLeaseId = 1;
|
|
113
|
+
private nextSequence = 1;
|
|
114
|
+
private scrolling = false;
|
|
115
|
+
private cacheBytes = 0;
|
|
116
|
+
private waveformCacheBytes = 0;
|
|
117
|
+
private readonly activeByBucket = { video: 0, composition: 0, general: 0 };
|
|
118
|
+
private readonly activeByKind: Record<ThumbnailJobKind, number> = {
|
|
119
|
+
video: 0,
|
|
120
|
+
image: 0,
|
|
121
|
+
composition: 0,
|
|
122
|
+
waveform: 0,
|
|
123
|
+
};
|
|
124
|
+
private readonly now: () => number;
|
|
125
|
+
|
|
126
|
+
constructor(
|
|
127
|
+
budgets: Readonly<TimelineViewportBudgets> = TIMELINE_VIEWPORT_BUDGETS,
|
|
128
|
+
now: () => number = Date.now,
|
|
129
|
+
) {
|
|
130
|
+
this.budgets = budgets;
|
|
131
|
+
this.now = now;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
acquire(request: ThumbnailRequest, listener: () => void): ThumbnailLease {
|
|
135
|
+
const scopedKey = createThumbnailRequestIdentity(request);
|
|
136
|
+
let entry = this.entries.get(scopedKey);
|
|
137
|
+
if (
|
|
138
|
+
entry?.state === "error" &&
|
|
139
|
+
entry.failedAt !== undefined &&
|
|
140
|
+
this.now() - entry.failedAt >= this.budgets.metadataFailureTtlMs
|
|
141
|
+
) {
|
|
142
|
+
this.deleteEntry(scopedKey, entry);
|
|
143
|
+
entry = this.entries.get(scopedKey);
|
|
144
|
+
}
|
|
145
|
+
if (!entry) {
|
|
146
|
+
entry = {
|
|
147
|
+
request,
|
|
148
|
+
scopedKey,
|
|
149
|
+
state: "queued",
|
|
150
|
+
leases: new Map(),
|
|
151
|
+
listeners: new Map(),
|
|
152
|
+
controller: null,
|
|
153
|
+
weight: 0,
|
|
154
|
+
disposed: false,
|
|
155
|
+
cached: false,
|
|
156
|
+
lastAccess: this.nextSequence++,
|
|
157
|
+
snapshot: Object.freeze({ status: "queued" }),
|
|
158
|
+
};
|
|
159
|
+
this.entries.set(scopedKey, entry);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const leaseId = this.nextLeaseId++;
|
|
163
|
+
entry.leases.set(leaseId, request.priority);
|
|
164
|
+
entry.listeners.set(leaseId, listener);
|
|
165
|
+
entry.lastAccess = this.nextSequence++;
|
|
166
|
+
this.pump();
|
|
167
|
+
|
|
168
|
+
let released = false;
|
|
169
|
+
return {
|
|
170
|
+
updatePriority: (priority) => {
|
|
171
|
+
const current = this.entries.get(scopedKey);
|
|
172
|
+
if (!current || !current.leases.has(leaseId)) return;
|
|
173
|
+
current.leases.set(leaseId, priority);
|
|
174
|
+
current.lastAccess = this.nextSequence++;
|
|
175
|
+
this.pump();
|
|
176
|
+
},
|
|
177
|
+
release: () => {
|
|
178
|
+
if (released) return;
|
|
179
|
+
released = true;
|
|
180
|
+
const current = this.entries.get(scopedKey);
|
|
181
|
+
if (!current) return;
|
|
182
|
+
current.leases.delete(leaseId);
|
|
183
|
+
current.listeners.delete(leaseId);
|
|
184
|
+
if (current.leases.size === 0) {
|
|
185
|
+
if (current.state === "queued") {
|
|
186
|
+
this.deleteEntry(scopedKey, current);
|
|
187
|
+
} else if (current.state === "loading") {
|
|
188
|
+
current.controller?.abort();
|
|
189
|
+
this.deleteEntry(scopedKey, current);
|
|
190
|
+
} else if (current.state === "ready" && !current.cached) {
|
|
191
|
+
this.deleteEntry(scopedKey, current);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
this.evict();
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
getSnapshot(
|
|
200
|
+
request: Pick<ThumbnailRequest, "key" | "projectId" | "sessionEpoch" | "kind" | "rich">,
|
|
201
|
+
): ThumbnailSnapshot {
|
|
202
|
+
const entry = this.entries.get(createThumbnailRequestIdentity(request));
|
|
203
|
+
if (!entry) return EMPTY_SNAPSHOT;
|
|
204
|
+
return entry.snapshot;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
setScrolling(scrolling: boolean): void {
|
|
208
|
+
if (this.scrolling === scrolling) return;
|
|
209
|
+
this.scrolling = scrolling;
|
|
210
|
+
if (!scrolling) this.pump();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Evict project cache entries that are no longer owned by mounted consumers. */
|
|
214
|
+
invalidateProject(projectId: string): void {
|
|
215
|
+
for (const [key, entry] of this.entries) {
|
|
216
|
+
if (entry.request.projectId !== projectId) continue;
|
|
217
|
+
if (entry.leases.size > 0) continue;
|
|
218
|
+
entry.controller?.abort();
|
|
219
|
+
this.deleteEntry(key, entry);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
getDiagnostics(): ThumbnailSchedulerDiagnostics {
|
|
224
|
+
let queued = 0;
|
|
225
|
+
let active = 0;
|
|
226
|
+
let leases = 0;
|
|
227
|
+
let cacheEntries = 0;
|
|
228
|
+
let waveformCacheEntries = 0;
|
|
229
|
+
for (const entry of this.entries.values()) {
|
|
230
|
+
if (entry.state === "queued") queued++;
|
|
231
|
+
if (entry.state === "loading") active++;
|
|
232
|
+
if (entry.cached) {
|
|
233
|
+
if (entry.request.kind === "waveform") waveformCacheEntries++;
|
|
234
|
+
else cacheEntries++;
|
|
235
|
+
}
|
|
236
|
+
leases += entry.leases.size;
|
|
237
|
+
}
|
|
238
|
+
return {
|
|
239
|
+
queued,
|
|
240
|
+
active,
|
|
241
|
+
leases,
|
|
242
|
+
cacheEntries,
|
|
243
|
+
cacheBytes: this.cacheBytes,
|
|
244
|
+
waveformCacheEntries,
|
|
245
|
+
waveformCacheBytes: this.waveformCacheBytes,
|
|
246
|
+
activeByKind: { ...this.activeByKind },
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
private pump(): void {
|
|
251
|
+
const queued = Array.from(this.entries.values())
|
|
252
|
+
.filter((entry) => entry.state === "queued" && entry.leases.size > 0)
|
|
253
|
+
.sort((left, right) => {
|
|
254
|
+
const priorityDelta = this.entryPriority(right) - this.entryPriority(left);
|
|
255
|
+
return priorityDelta || left.lastAccess - right.lastAccess;
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
for (const entry of queued) {
|
|
259
|
+
if (this.scrolling && entry.request.rich) continue;
|
|
260
|
+
const bucket = concurrencyBucket(entry.request.kind);
|
|
261
|
+
if (this.activeByBucket[bucket] >= this.bucketLimit(bucket)) continue;
|
|
262
|
+
this.start(entry, bucket);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private start(entry: ThumbnailEntry, bucket: "video" | "composition" | "general"): void {
|
|
267
|
+
entry.state = "loading";
|
|
268
|
+
entry.snapshot = Object.freeze({ status: "loading" });
|
|
269
|
+
const controller = new AbortController();
|
|
270
|
+
entry.controller = controller;
|
|
271
|
+
this.activeByBucket[bucket]++;
|
|
272
|
+
this.activeByKind[entry.request.kind]++;
|
|
273
|
+
this.notify(entry);
|
|
274
|
+
|
|
275
|
+
const pending = this.loadWithTimeout(entry, controller);
|
|
276
|
+
void pending
|
|
277
|
+
.then((result) => this.acceptResult(entry, controller, result))
|
|
278
|
+
.catch((reason: unknown) => {
|
|
279
|
+
if (this.entries.get(entry.scopedKey) !== entry) return;
|
|
280
|
+
if (controller.signal.aborted && entry.leases.size === 0) {
|
|
281
|
+
this.deleteEntry(entry.scopedKey, entry);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
entry.state = "error";
|
|
285
|
+
entry.error = errorFrom(reason);
|
|
286
|
+
entry.failedAt = this.now();
|
|
287
|
+
entry.snapshot = Object.freeze({ status: "error", error: entry.error });
|
|
288
|
+
this.notify(entry);
|
|
289
|
+
this.evictFailures();
|
|
290
|
+
})
|
|
291
|
+
.finally(() => {
|
|
292
|
+
if (entry.controller === controller) entry.controller = null;
|
|
293
|
+
this.activeByBucket[bucket]--;
|
|
294
|
+
this.activeByKind[entry.request.kind]--;
|
|
295
|
+
this.pump();
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
private acceptResult(
|
|
300
|
+
entry: ThumbnailEntry,
|
|
301
|
+
controller: AbortController,
|
|
302
|
+
result: ThumbnailLoadedResult,
|
|
303
|
+
): void {
|
|
304
|
+
if (controller.signal.aborted || this.entries.get(entry.scopedKey) !== entry) {
|
|
305
|
+
this.safeDispose(result.dispose);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
this.validateResult(result);
|
|
309
|
+
entry.state = "ready";
|
|
310
|
+
entry.value = result.value;
|
|
311
|
+
entry.weight = result.weight;
|
|
312
|
+
entry.dispose = result.dispose;
|
|
313
|
+
this.cacheResult(entry);
|
|
314
|
+
entry.snapshot = Object.freeze({ status: "ready", value: result.value });
|
|
315
|
+
this.notify(entry);
|
|
316
|
+
this.evict();
|
|
317
|
+
if (!entry.cached && entry.leases.size === 0) this.deleteEntry(entry.scopedKey, entry);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
private validateResult(result: ThumbnailLoadedResult): void {
|
|
321
|
+
if (Number.isFinite(result.weight) && result.weight >= 0) return;
|
|
322
|
+
this.safeDispose(result.dispose);
|
|
323
|
+
throw new RangeError("Thumbnail result weight must be finite and non-negative");
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
private cacheResult(entry: ThumbnailEntry): void {
|
|
327
|
+
const isWaveform = entry.request.kind === "waveform";
|
|
328
|
+
const byteBudget = isWaveform
|
|
329
|
+
? this.budgets.waveformCacheBytes
|
|
330
|
+
: this.budgets.thumbnailCacheBytes;
|
|
331
|
+
entry.cached = entry.weight <= byteBudget;
|
|
332
|
+
if (!entry.cached) return;
|
|
333
|
+
if (isWaveform) this.waveformCacheBytes += entry.weight;
|
|
334
|
+
else this.cacheBytes += entry.weight;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
private entryPriority(entry: ThumbnailEntry): number {
|
|
338
|
+
let score = -1;
|
|
339
|
+
for (const priority of entry.leases.values()) {
|
|
340
|
+
score = Math.max(score, PRIORITY_SCORE[priority]);
|
|
341
|
+
}
|
|
342
|
+
return score;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
private bucketLimit(bucket: "video" | "composition" | "general"): number {
|
|
346
|
+
if (bucket === "video") return this.budgets.concurrentVideoDecodes;
|
|
347
|
+
if (bucket === "composition") return this.budgets.concurrentCompositionFetches;
|
|
348
|
+
return this.budgets.concurrentMetadataJobs;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
private evict(): void {
|
|
352
|
+
this.evictPartition(false);
|
|
353
|
+
this.evictPartition(true);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
private evictPartition(waveform: boolean): void {
|
|
357
|
+
const cached = Array.from(this.entries.values()).filter(
|
|
358
|
+
(entry) => entry.cached && (entry.request.kind === "waveform") === waveform,
|
|
359
|
+
);
|
|
360
|
+
const perProject = new Map<string, number>();
|
|
361
|
+
for (const entry of cached) {
|
|
362
|
+
perProject.set(entry.request.projectId, (perProject.get(entry.request.projectId) ?? 0) + 1);
|
|
363
|
+
}
|
|
364
|
+
cached.sort((left, right) => left.lastAccess - right.lastAccess);
|
|
365
|
+
|
|
366
|
+
let cacheEntries = cached.length;
|
|
367
|
+
for (const entry of cached) {
|
|
368
|
+
const projectEntries = perProject.get(entry.request.projectId) ?? 0;
|
|
369
|
+
if (!this.isCacheOverBudget(waveform, cacheEntries, projectEntries)) continue;
|
|
370
|
+
this.uncache(entry);
|
|
371
|
+
cacheEntries--;
|
|
372
|
+
perProject.set(entry.request.projectId, projectEntries - 1);
|
|
373
|
+
if (entry.leases.size === 0) this.deleteEntry(entry.scopedKey, entry);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
private isCacheOverBudget(
|
|
378
|
+
waveform: boolean,
|
|
379
|
+
cacheEntries: number,
|
|
380
|
+
projectEntries: number,
|
|
381
|
+
): boolean {
|
|
382
|
+
const entryBudget = waveform
|
|
383
|
+
? this.budgets.waveformCacheEntries
|
|
384
|
+
: this.budgets.thumbnailCacheEntries;
|
|
385
|
+
const byteBudget = waveform
|
|
386
|
+
? this.budgets.waveformCacheBytes
|
|
387
|
+
: this.budgets.thumbnailCacheBytes;
|
|
388
|
+
const cacheBytes = waveform ? this.waveformCacheBytes : this.cacheBytes;
|
|
389
|
+
const projectOverBudget =
|
|
390
|
+
!waveform && projectEntries > this.budgets.thumbnailCacheEntriesPerProject;
|
|
391
|
+
return cacheEntries > entryBudget || cacheBytes > byteBudget || projectOverBudget;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
private uncache(entry: ThumbnailEntry): void {
|
|
395
|
+
entry.cached = false;
|
|
396
|
+
if (entry.request.kind === "waveform") this.waveformCacheBytes -= entry.weight;
|
|
397
|
+
else this.cacheBytes -= entry.weight;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
private notify(entry: ThumbnailEntry): void {
|
|
401
|
+
for (const listener of new Set(entry.listeners.values())) listener();
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
private loadWithTimeout(
|
|
405
|
+
entry: ThumbnailEntry,
|
|
406
|
+
controller: AbortController,
|
|
407
|
+
): Promise<ThumbnailLoadedResult> {
|
|
408
|
+
let load: Promise<ThumbnailLoadedResult>;
|
|
409
|
+
try {
|
|
410
|
+
load = entry.request.load(controller.signal);
|
|
411
|
+
} catch (reason) {
|
|
412
|
+
load = Promise.reject(reason);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return new Promise((resolve, reject) => {
|
|
416
|
+
let settled = false;
|
|
417
|
+
const timeout = setTimeout(() => {
|
|
418
|
+
if (settled) return;
|
|
419
|
+
settled = true;
|
|
420
|
+
controller.abort();
|
|
421
|
+
reject(
|
|
422
|
+
new Error(`Thumbnail load timed out after ${this.budgets.thumbnailLoadTimeoutMs}ms`),
|
|
423
|
+
);
|
|
424
|
+
}, this.budgets.thumbnailLoadTimeoutMs);
|
|
425
|
+
|
|
426
|
+
load.then(
|
|
427
|
+
(result) => {
|
|
428
|
+
if (settled) {
|
|
429
|
+
this.safeDispose(result.dispose);
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
settled = true;
|
|
433
|
+
clearTimeout(timeout);
|
|
434
|
+
resolve(result);
|
|
435
|
+
},
|
|
436
|
+
(reason: unknown) => {
|
|
437
|
+
if (settled) return;
|
|
438
|
+
settled = true;
|
|
439
|
+
clearTimeout(timeout);
|
|
440
|
+
reject(reason);
|
|
441
|
+
},
|
|
442
|
+
);
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
private deleteEntry(key: string, entry: ThumbnailEntry): void {
|
|
447
|
+
if (this.entries.get(key) !== entry) return;
|
|
448
|
+
this.entries.delete(key);
|
|
449
|
+
entry.snapshot = EMPTY_SNAPSHOT;
|
|
450
|
+
this.notify(entry);
|
|
451
|
+
if (entry.cached) {
|
|
452
|
+
entry.cached = false;
|
|
453
|
+
if (entry.request.kind === "waveform") this.waveformCacheBytes -= entry.weight;
|
|
454
|
+
else this.cacheBytes -= entry.weight;
|
|
455
|
+
}
|
|
456
|
+
if (!entry.disposed) {
|
|
457
|
+
entry.disposed = true;
|
|
458
|
+
this.safeDispose(entry.dispose);
|
|
459
|
+
}
|
|
460
|
+
entry.listeners.clear();
|
|
461
|
+
entry.leases.clear();
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
private evictFailures(): void {
|
|
465
|
+
const failed = Array.from(this.entries.values())
|
|
466
|
+
.filter((entry) => entry.state === "error" && entry.leases.size === 0)
|
|
467
|
+
.sort((left, right) => left.lastAccess - right.lastAccess);
|
|
468
|
+
const overflow = failed.length - this.budgets.thumbnailCacheEntries;
|
|
469
|
+
for (const entry of failed.slice(0, Math.max(0, overflow))) {
|
|
470
|
+
this.deleteEntry(entry.scopedKey, entry);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
private safeDispose(dispose: (() => void) | undefined): void {
|
|
475
|
+
try {
|
|
476
|
+
dispose?.();
|
|
477
|
+
} catch {
|
|
478
|
+
// Cleanup is best-effort; one broken resource must not block the remaining cleanup.
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
export const thumbnailScheduler = new ThumbnailScheduler();
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { decodeVideoThumbnail, videoThumbnailTimestamps } from "./thumbnailVideoDecoder";
|
|
5
|
+
|
|
6
|
+
const dispose = vi.fn();
|
|
7
|
+
const canvasesAtTimestamps = vi.fn();
|
|
8
|
+
const input = {
|
|
9
|
+
getPrimaryVideoTrack: vi.fn(),
|
|
10
|
+
dispose,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
vi.mock("mediabunny", () => ({
|
|
14
|
+
ALL_FORMATS: {},
|
|
15
|
+
UrlSource: class {
|
|
16
|
+
constructor(readonly url: string) {}
|
|
17
|
+
},
|
|
18
|
+
Input: class {
|
|
19
|
+
getPrimaryVideoTrack = input.getPrimaryVideoTrack;
|
|
20
|
+
dispose = input.dispose;
|
|
21
|
+
},
|
|
22
|
+
CanvasSink: class {
|
|
23
|
+
canvasesAtTimestamps = canvasesAtTimestamps;
|
|
24
|
+
},
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
vi.clearAllMocks();
|
|
29
|
+
vi.spyOn(URL, "createObjectURL").mockReturnValueOnce("blob:one").mockReturnValueOnce("blob:two");
|
|
30
|
+
vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
|
|
31
|
+
HTMLCanvasElement.prototype.toBlob = function toBlob(callback) {
|
|
32
|
+
callback(new Blob(["frame"], { type: "image/jpeg" }));
|
|
33
|
+
};
|
|
34
|
+
input.getPrimaryVideoTrack.mockResolvedValue({
|
|
35
|
+
getDisplayWidth: vi.fn(async () => 1080),
|
|
36
|
+
getDisplayHeight: vi.fn(async () => 1920),
|
|
37
|
+
getDurationFromMetadata: vi.fn(async () => 10),
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("videoThumbnailTimestamps", () => {
|
|
42
|
+
it("uses the midpoint for a poster and sorted sparse points for a strip", () => {
|
|
43
|
+
expect(videoThumbnailTimestamps(2, 6, 1)).toEqual([5]);
|
|
44
|
+
expect(videoThumbnailTimestamps(2, 6, 4)).toEqual([2, 4, 6, 8]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("clamps invalid source ranges", () => {
|
|
48
|
+
expect(videoThumbnailTimestamps(-2, Number.NaN, 0)).toEqual([0]);
|
|
49
|
+
expect(videoThumbnailTimestamps(2, 8, Number.NaN)).toEqual([6]);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("decodeVideoThumbnail", () => {
|
|
54
|
+
it("extracts sparse frames, returns object URLs, and disposes once", async () => {
|
|
55
|
+
const canvas = document.createElement("canvas");
|
|
56
|
+
canvasesAtTimestamps.mockImplementation(async function* (timestamps: number[]) {
|
|
57
|
+
expect(timestamps).toEqual([2, 8]);
|
|
58
|
+
yield { canvas, timestamp: 2, duration: 1 };
|
|
59
|
+
yield { canvas, timestamp: 8, duration: 1 };
|
|
60
|
+
});
|
|
61
|
+
const result = await decodeVideoThumbnail(
|
|
62
|
+
{ source: "/clip.mp4", sourceStart: 2, sourceRangeDuration: 6, frameCount: 2 },
|
|
63
|
+
new AbortController().signal,
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
expect(result.value).toEqual({
|
|
67
|
+
kind: "filmstrip",
|
|
68
|
+
urls: ["blob:one", "blob:two"],
|
|
69
|
+
aspect: 9 / 16,
|
|
70
|
+
});
|
|
71
|
+
result.dispose?.();
|
|
72
|
+
result.dispose?.();
|
|
73
|
+
expect(URL.revokeObjectURL).toHaveBeenCalledTimes(2);
|
|
74
|
+
expect(dispose).toHaveBeenCalledTimes(1);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("releases input and degrades when the source has no video track", async () => {
|
|
78
|
+
input.getPrimaryVideoTrack.mockResolvedValue(null);
|
|
79
|
+
await expect(
|
|
80
|
+
decodeVideoThumbnail({ source: "/audio.mp3", frameCount: 1 }, new AbortController().signal),
|
|
81
|
+
).rejects.toThrow("no decodable video track");
|
|
82
|
+
expect(dispose).toHaveBeenCalledTimes(1);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("revokes partial results when cancellation lands during extraction", async () => {
|
|
86
|
+
const controller = new AbortController();
|
|
87
|
+
const canvas = document.createElement("canvas");
|
|
88
|
+
canvasesAtTimestamps.mockImplementation(async function* () {
|
|
89
|
+
yield { canvas, timestamp: 1, duration: 1 };
|
|
90
|
+
controller.abort();
|
|
91
|
+
yield { canvas, timestamp: 2, duration: 1 };
|
|
92
|
+
});
|
|
93
|
+
await expect(
|
|
94
|
+
decodeVideoThumbnail({ source: "/clip.mp4", frameCount: 2 }, controller.signal),
|
|
95
|
+
).rejects.toMatchObject({ name: "AbortError" });
|
|
96
|
+
expect(URL.revokeObjectURL).toHaveBeenCalledTimes(1);
|
|
97
|
+
expect(dispose).toHaveBeenCalledTimes(1);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("stops after metadata cancellation before occupying the decoder", async () => {
|
|
101
|
+
const controller = new AbortController();
|
|
102
|
+
let resolveWidth!: (width: number) => void;
|
|
103
|
+
const width = new Promise<number>((resolve) => {
|
|
104
|
+
resolveWidth = resolve;
|
|
105
|
+
});
|
|
106
|
+
const getDisplayWidth = vi.fn(() => width);
|
|
107
|
+
const getDurationFromMetadata = vi.fn(async () => 10);
|
|
108
|
+
input.getPrimaryVideoTrack.mockResolvedValue({
|
|
109
|
+
getDisplayWidth,
|
|
110
|
+
getDisplayHeight: vi.fn(async () => 1920),
|
|
111
|
+
getDurationFromMetadata,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const decoding = decodeVideoThumbnail(
|
|
115
|
+
{ source: "/clip.mp4", frameCount: 1 },
|
|
116
|
+
controller.signal,
|
|
117
|
+
);
|
|
118
|
+
await vi.waitFor(() => expect(getDisplayWidth).toHaveBeenCalledOnce());
|
|
119
|
+
controller.abort();
|
|
120
|
+
resolveWidth(1080);
|
|
121
|
+
|
|
122
|
+
await expect(decoding).rejects.toMatchObject({ name: "AbortError" });
|
|
123
|
+
expect(getDurationFromMetadata).not.toHaveBeenCalled();
|
|
124
|
+
expect(canvasesAtTimestamps).not.toHaveBeenCalled();
|
|
125
|
+
expect(dispose).toHaveBeenCalledTimes(1);
|
|
126
|
+
});
|
|
127
|
+
});
|