Extract thumbnails from videos in browsers with ffmpeg.wasm
Select a local video, enter timestamps in seconds, and display PNG previews without uploading the video. This walkthrough builds a small browser app with ffmpeg.wasm and Vite, including the file picker, matching Wasm assets, error feedback, and cleanup between runs.
Why extract thumbnails in the browser?
A local preview lets someone choose a useful frame before deciding whether to upload a video. In this example, the browser downloads application code and the FFmpeg core from your local server; the selected video and generated images stay in browser memory. That describes this app’s behavior, not a privacy guarantee for every application that uses ffmpeg.wasm.
Meet ffmpeg.wasm
The ffmpeg.wasm wrapper runs FFmpeg in its own web worker. You copy a file into its virtual filesystem, execute a familiar FFmpeg command, and read the result back. The single-threaded core used here still runs off the main browser thread.
Use a short, unencrypted H.264 MP4 to start. This example limits inputs to 25 MiB and requests to six timestamps. Those are demo limits, not a guarantee that every device can process such a file. The verified environment is Linux, Node.js 24.15.0, Yarn 4.12.0, and Chromium 145.0.7632.6. Node builds and serves the app; the browser performs the media processing. Other browsers and mobile devices need their own testing.
Install and initialize
With Node.js 24.15.0 and Corepack available, paste this into a Bash-compatible shell in a scratch
directory. It creates a new wasm-thumbnails project and refuses to overwrite an existing directory.
The subshell keeps your shell’s current directory and options unchanged. The local lockfile and
TypeScript configuration keep the project separate from enclosing workspace configuration.
(
set -eu
mkdir wasm-thumbnails
cd wasm-thumbnails
cat > package.json <<'JSON'
{
"name": "wasm-thumbnails",
"private": true,
"type": "module",
"packageManager": "yarn@4.12.0",
"dependencies": {
"@ffmpeg/core": "0.12.10",
"@ffmpeg/ffmpeg": "0.12.15",
"@ffmpeg/util": "0.12.2",
"vite": "8.3.1"
}
}
JSON
printf '{"compilerOptions":{"target":"ES2022"}}\n' > tsconfig.json
touch yarn.lock
YARN_NODE_LINKER=node-modules corepack yarn install
)
Keep the generated yarn.lock for repeatable dependency resolution. Save the following three files
inside wasm-thumbnails. First, copy-core.ts copies the ESM core from the installed package so the
JavaScript and Wasm files match. The official loading guide
specifies ESM assets for Vite; same-origin assets can be loaded directly without blob URL wrappers.
import { copyFile, mkdir } from 'node:fs/promises'
await mkdir('public/core', { recursive: true })
for (const name of ['ffmpeg-core.js', 'ffmpeg-core.wasm']) {
await copyFile(`node_modules/@ffmpeg/core/dist/esm/${name}`, `public/core/${name}`)
}
Next, save index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Local video thumbnails</title>
<link rel="icon" href="data:," />
<style>
body { font-family: system-ui, sans-serif; max-width: 48rem; margin: 2rem auto; padding: 1rem; }
label { display: block; margin-block: 1rem; }
input { max-width: 100%; }
figure { margin-inline: 0; }
img { max-width: 100%; height: auto; }
</style>
</head>
<body>
<h1>Local video thumbnails</h1>
<form id="picker">
<fieldset id="controls">
<legend>Choose a video and timestamps</legend>
<label>Video <input id="video" type="file" accept="video/*" required /></label>
<label>Seconds, separated by commas
<input id="times" type="text" value="0.5, 1.5" required />
</label>
<button type="submit">Extract thumbnails</button>
</fieldset>
</form>
<p id="status" role="status">Choose a video to begin.</p>
<section id="results" aria-label="Thumbnails"></section>
<script type="module" src="/main.ts"></script>
</body>
</html>
Satisfy browser requirements
Serve the app over localhost as shown below, rather than opening index.html as a file. This
single-threaded @ffmpeg/core works without SharedArrayBuffer or COOP/COEP headers. The wrapper
still needs module workers and WebAssembly. The core download is about 31 MiB before HTTP compression,
so the first extraction can take noticeably longer than later ones.
Switching to @ffmpeg/core-mt is a separate integration: the
threaded loading example
requires an additional worker asset and the security conditions for SharedArrayBuffer, including
cross-origin isolation. Do not add those requirements to this single-threaded example by default.
Production HTTPS, content security policy, and hosting under a subpath need separate configuration;
this walkthrough serves from the origin root.
Extract a single thumbnail
Save this complete program as main.ts. extractThumbnail() selects the first video stream,
seeks to the requested time, and writes one PNG. Each invocation uses unique virtual filenames,
checks the exit status and output bytes, then removes its files even on failure.
import { FFmpeg } from '@ffmpeg/ffmpeg'
import { fetchFile } from '@ffmpeg/util'
const ffmpeg = new FFmpeg()
let extracting = false
async function extractThumbnail(videoFile: File, seconds: number): Promise<Blob> {
if (!Number.isFinite(seconds) || seconds < 0) throw new Error('Invalid timestamp')
if (extracting) throw new Error('A thumbnail operation is already running')
extracting = true
const id = crypto.randomUUID()
const input = `input-${id}.mp4`
const output = `thumb-${id}.png`
try {
if (!ffmpeg.loaded) {
await ffmpeg.load({
coreURL: new URL('/core/ffmpeg-core.js', location.href).href,
wasmURL: new URL('/core/ffmpeg-core.wasm', location.href).href,
})
}
await ffmpeg.writeFile(input, await fetchFile(videoFile))
const code = await ffmpeg.exec([
'-xerror', '-ss', String(seconds), '-i', input,
'-map', '0:v:0', '-frames:v', '1', '-vf', 'scale=320:-1', output,
])
if (code !== 0) throw new Error('FFmpeg could not extract this frame')
// A seek past the end can succeed without producing a usable image.
const data = await ffmpeg.readFile(output)
if (!(data instanceof Uint8Array) || data.length === 0) {
throw new Error('No thumbnail was produced')
}
return new Blob([new Uint8Array(data)], { type: 'image/png' })
} finally {
// A failed command may never have created one or both files.
await Promise.allSettled([ffmpeg.deleteFile(input), ffmpeg.deleteFile(output)])
extracting = false
}
}
function parseTimes(value: string): number[] {
const parts = value.split(',').map((part) => part.trim())
if (parts.length > 6 || parts.some((part) => !/^\d+(\.\d+)?$/.test(part))) {
throw new Error('Enter one to six nonnegative timestamps in seconds.')
}
const times = parts.map(Number)
if (times.some((time) => !Number.isFinite(time))) throw new Error('Invalid timestamp')
return times
}
const form = document.getElementById('picker')
const controls = document.getElementById('controls')
const video = document.getElementById('video')
const times = document.getElementById('times')
const status = document.getElementById('status')
const results = document.getElementById('results')
if (!(form instanceof HTMLFormElement) || !(controls instanceof HTMLFieldSetElement) ||
!(video instanceof HTMLInputElement) || !(times instanceof HTMLInputElement) ||
!status || !results) {
throw new Error('Missing page controls')
}
let running = false
const previewUrls: string[] = []
function clearPreviews(container: HTMLElement): void {
container.replaceChildren()
for (const url of previewUrls) URL.revokeObjectURL(url)
previewUrls.length = 0
}
form.addEventListener('change', () => {
if (running) return
clearPreviews(results)
status.textContent = 'Ready to extract.'
})
form.addEventListener('submit', async (event) => {
event.preventDefault()
if (running) return
const file = video.files?.[0]
if (!file) return
running = true
controls.disabled = true
clearPreviews(results)
status.textContent = 'Loading FFmpeg and extracting…'
const deadline = setTimeout(() => ffmpeg.terminate(), 60_000)
try {
if (file.size === 0 || file.size > 25 * 1024 * 1024) {
throw new Error('Choose a nonempty video of at most 25 MiB.')
}
const marks = parseTimes(times.value)
const figures = []
for (const mark of marks) {
const blob = await extractThumbnail(file, mark)
const url = URL.createObjectURL(blob)
previewUrls.push(url)
const image = new Image()
image.alt = `Video frame at ${mark} seconds`
image.src = url
await image.decode()
const caption = document.createElement('figcaption')
caption.textContent = `${mark} s`
const figure = document.createElement('figure')
figure.append(image, caption)
figures.push(figure)
}
results.replaceChildren(...figures)
status.textContent = `${marks.length} thumbnail(s) ready.`
} catch {
clearPreviews(results)
ffmpeg.terminate()
status.textContent = 'Could not extract thumbnails. Use a small valid video and times within its duration, then retry.'
} finally {
clearTimeout(deadline)
controls.disabled = false
running = false
}
})
The -ss option seeks before decoding, and FFmpeg’s
default accurate seeking discards frames before the requested position when transcoding. Video
frames occur at discrete times: a timestamp between frames does not create an interpolated frame.
The output is 320 pixels wide. This is a preview extractor, not a whole-file integrity check;
a successful early thumbnail does not establish that a video’s later packets are intact.
Grab multiple thumbnails sequentially
The form’s loop awaits each timestamp on one FFmpeg instance. It copies the input again for each frame, favoring a small, self-contained extraction function over a more elaborate batch API. Results appear together only after every requested image decodes. A new submission clears the previous set; a failed batch leaves no partial previews.
Run the following from the directory containing wasm-thumbnails. Copying the core overwrites only
the two project assets, and Vite replaces the project’s dist build. Each step runs only if the
previous one succeeds:
(
cd wasm-thumbnails &&
node copy-core.ts &&
corepack yarn vite build &&
corepack yarn vite preview --host 127.0.0.1 --port 4173 --strictPort
)
Open http://127.0.0.1:4173, choose a video longer than 2 seconds, leave the timestamps at 0.5, 1.5,
and press Extract thumbnails. Two images should appear with their
requested times underneath and 2 thumbnail(s) ready. above them.
Use a single value such as 0.5 for one thumbnail. Stop the server with Ctrl+C; if port 4173 is busy,
choose another port in the command and URL. Vite’s
preview server is for checking a local build.
Keep the UI responsive with a web worker
The wrapper already owns the worker. The form disables its file picker, timestamp input, and submit button while work runs, and ignores duplicate submissions. It does not offer cancellation or let a new selection overtake an active batch. The 60-second deadline terminates the worker if loading or extraction stalls; the next submission loads a fresh instance of the core.
Performance tips
The core loads on the first submission and is reused after successful batches. Temporary virtual files are deleted after each extraction. The preview URLs remain valid while their images are shown, then are revoked when inputs change or another run starts. Reloading or closing the page discards the session; the app does not save thumbnails to disk.
Deleting virtual files does not promise that the Wasm runtime immediately gives all allocated memory back to the operating system. A worker can keep its memory capacity for reuse. Smaller output images also do not eliminate the cost of decoding a high-resolution input. Test representative files on the devices you intend to support before raising the demo limits.
Browser versus server processing
Browser extraction is useful for a preview before upload, but its download time, memory use, and processing speed depend on the user’s device. Server extraction requires sending the video and planning for processing capacity and retention. It can suit workflows that need persistent results or consistent processing resources. Neither approach establishes universal codec support or an unlimited capacity by itself.
Troubleshoot common issues
- No previews: check that the video is nonempty, within the size limit, and has a decodable video
stream. Enter decimal seconds such as
1.25, not00:00:01.25. A timestamp at or beyond the end may produce no frame; retry with an earlier time. - Core loading fails: confirm that both
/core/ffmpeg-core.jsand/core/ffmpeg-core.wasmare served from the build. Repeat the copy-and-build command after changing the core version. The JavaScript wrapper and core have separate version numbers; they need not share a version string. - Extraction fails or reaches the deadline: try a shorter, lower-resolution video. The failure handler discards previews and terminates the worker so the next submission can start cleanly.
SharedArrayBuffererrors: check whether you accidentally substituted the multithreaded core. Adding isolation headers is not a fix for missing assets in this single-threaded setup.
Move beyond local previews
If the target devices cannot handle your videos, consider an upload-based workflow with explicit user consent. The video thumbnail documentation describes Transloadit’s server-side option. Keep the local preview useful on its own: a failed extraction should let someone choose a different file or timestamp without losing the rest of their work.
