Web Workers
Offloading waveform extraction and thumbnail generation to background threads
Waveform extraction and batch thumbnail generation are CPU-intensive. Running them on the main thread causes frame drops and jank in the editor UI. @timelinx/media-web provides worker script entry points and message contract types to run these operations in dedicated Worker threads.
Worker Scripts
Two worker entry points are exported from @timelinx/media-web/workers:
import WaveformWorker from '@timelinx/media-web/workers/waveform-worker?worker';
import ThumbnailWorker from '@timelinx/media-web/workers/thumbnail-worker?worker';The ?worker suffix is a Vite-specific import. In other bundlers, adjust the import accordingly.
Waveform Worker
Message Types
// From @timelinx/core
type WaveformWorkerMessage = {
type: 'EXTRACT_WAVEFORM';
id: string;
arrayBuffer: ArrayBuffer;
config?: { samplesPerPixel?: number; channelCount?: number };
};
type WaveformWorkerResponse =
| { type: 'WAVEFORM_RESULT'; id: string; data: WaveformData }
| { type: 'WAVEFORM_ERROR'; id: string; message: string };Usage
const worker = new WaveformWorker();
function extractWaveformInWorker(file: File, assetId: string): Promise<WaveformData> {
return new Promise((resolve, reject) => {
const arrayBuffer = await file.arrayBuffer();
worker.postMessage(
{ type: 'EXTRACT_WAVEFORM', id: assetId, arrayBuffer },
[arrayBuffer], // Transfer ownership to avoid copying
);
worker.onmessage = (e: MessageEvent<WaveformWorkerResponse>) => {
if (e.data.id !== assetId) return;
if (e.data.type === 'WAVEFORM_RESULT') resolve(e.data.data);
if (e.data.type === 'WAVEFORM_ERROR') reject(new Error(e.data.message));
};
});
}Transfer ArrayBuffer ownership
Pass [arrayBuffer] as the second argument to postMessage to transfer the buffer to the worker without copying. The buffer becomes unusable on the main thread after this - read all metadata you need before posting.
Thumbnail Worker
Message Types
type ThumbnailWorkerMessage = {
type: 'GENERATE_THUMBNAIL';
id: string;
clipId: string;
mediaFrame: number;
filePath: string;
width: number;
height: number;
quality?: number;
};
type ThumbnailWorkerResponse =
| { type: 'THUMBNAIL_RESULT'; id: string; clipId: string; mediaFrame: number; bitmap: ImageBitmap }
| { type: 'THUMBNAIL_ERROR'; id: string; clipId: string; message: string };Usage
const thumbnailWorker = new ThumbnailWorker();
function requestThumbnail(clipId: string, mediaFrame: number, filePath: string) {
thumbnailWorker.postMessage({
type: 'GENERATE_THUMBNAIL',
id: `${clipId}-${mediaFrame}`,
clipId,
mediaFrame,
filePath,
width: 160,
height: 90,
quality: 0.7,
});
}
thumbnailWorker.onmessage = (e: MessageEvent<ThumbnailWorkerResponse>) => {
if (e.data.type === 'THUMBNAIL_RESULT') {
thumbnailCache.set(`${e.data.clipId}-${e.data.mediaFrame}`, e.data.bitmap);
// Trigger re-render of the relevant TimelineClip
}
};Worker Lifecycle
Workers are persistent - create one instance per worker type and reuse it for the lifetime of the editor session:
// Create once (outside components)
const waveformWorker = new WaveformWorker();
const thumbnailWorker = new ThumbnailWorker();
// Terminate when the user closes the project
waveformWorker.terminate();
thumbnailWorker.terminate();ThumbnailQueue and ThumbnailCache
For high-volume thumbnail requests (long timelines with many clips), use the queue and cache utilities from @timelinx/core to avoid flooding the worker:
import { ThumbnailQueue, ThumbnailCache } from '@timelinx/core';
const cache = new ThumbnailCache({ maxSize: 300 });
const queue = new ThumbnailQueue({
maxConcurrent: 4,
onRequest: (entry) => {
thumbnailWorker.postMessage({
type: 'GENERATE_THUMBNAIL',
id: entry.id,
...entry.request,
});
},
});
// Enqueue visible clips at high priority, off-screen at low
function requestVisibleThumbnails(visibleClips: Clip[], ppf: number) {
for (const clip of visibleClips) {
const key = `${clip.id}-${clip.mediaIn}`;
if (cache.has(key)) continue;
queue.enqueue({
id: key,
request: { clipId: clip.id, mediaFrame: clip.mediaIn, width: 160, height: 90 },
priority: 'high',
});
}
}