A file grid full of videos needs poster images, and generating them is where many web apps reach for a server round-trip or a heavyweight bundled codec. Neither fits a local-first tool: the video should never leave the disk, and shipping a multi-megabyte engine to make a 160-pixel thumbnail is overkill.
ZumiFiles grabs a single representative frame using the browser's own media pipeline - a hidden <video> element seeked to a timestamp and painted to a canvas - then caches the result as a small sidecar image so it never has to decode that frame again.
Key Technical Concepts Covered
- Seek, don't play: jump to one timestamp and decode a single frame.
- Canvas capture: paint the frame and export a compressed JPEG.
- Object-URL hygiene: revoke blob URLs so buffers don't leak.
- Sidecar caching: persist the thumbnail next to the file for instant reloads.
Part of the engineering behind ZumiFiles.
Decoding One Frame
You don't need to play a video to see it - setting currentTime and waiting for seeked decodes exactly one frame. ZumiFiles seeks a short way in (to skip black intro frames) and draws that frame to a canvas:
async function grabFrame(file, atSeconds = 1) {
const url = URL.createObjectURL(file);
const video = document.createElement('video');
video.muted = true; video.src = url;
await new Promise((res) => (video.onloadedmetadata = res));
video.currentTime = Math.min(atSeconds, video.duration / 2);
await new Promise((res) => (video.onseeked = res));
const w = 320, h = Math.round(320 * (video.videoHeight / video.videoWidth));
// OffscreenCanvas gives us convertToBlob(); a DOM canvas would use toBlob().
const canvas = new OffscreenCanvas(w, h);
canvas.getContext('2d').drawImage(video, 0, 0, w, h);
URL.revokeObjectURL(url); // release the decoded buffer
return canvas.convertToBlob({ type: 'image/jpeg', quality: 0.8 });
}
Caching as a Sidecar
Decoding is cheap but not free, so ZumiFiles writes the thumbnail once to a dot-prefixed sidecar image beside the video. On the next scan it loads that file instantly instead of decoding again - the same sidecar approach it uses for metadata:
async function cacheThumbnail(dirHandle, fileName, blob) {
const thumbName = `.${fileName}.thumbnail.jpg`;
const handle = await dirHandle.getFileHandle(thumbName, { create: true });
const writable = await handle.createWritable();
await writable.write(blob);
await writable.close();
}
When a persisted thumbnail already exists, the grid skips decoding entirely and streams the cached image - which is also how ZumiFiles shows posters for formats a browser can't decode live, like RAW photos and PSDs.
Conclusion: The Browser Already Has a Decoder
Between a hidden video element, a canvas, and a sidecar cache, ZumiFiles produces video posters with no server and no bundled codec. Seek one frame, paint it, revoke the URL, and cache the result - a full thumbnailing pipeline in a few dozen lines, running entirely on the user's machine.