How to Generate an HTML Video Thumbnail Preview
Create video thumbnails in the browser with HTML, canvas, seeking, CORS handling, export options, troubleshooting, and an API alternative.
Direct answer: put the video URL in an HTML <video> element, wait until media data is ready, draw the frame into a <canvas>, then export the canvas with toBlob() or toDataURL(). To choose a later frame, set currentTime and wait for the seeked event before drawing. Remote videos must allow CORS access or the canvas becomes restricted.
1. Minimal first-frame thumbnail
This complete page captures the first available frame after loadeddata. It uses the video’s intrinsic dimensions so the exported image is not accidentally limited by CSS size.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Video thumbnail</title>
<style>
video, canvas { max-width: 100%; display: block; margin: 1rem 0; }
</style>
</head>
<body>
<video id="video" controls preload="metadata"
src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
crossorigin="anonymous"></video>
<canvas id="canvas" hidden></canvas>
<img id="preview" alt="Video thumbnail preview">
<a id="download" hidden download="thumbnail.jpg">Download thumbnail</a>
<script>
const video = document.querySelector('#video');
const canvas = document.querySelector('#canvas');
const preview = document.querySelector('#preview');
const download = document.querySelector('#download');
video.addEventListener('loadeddata', () => {
if (!video.videoWidth || !video.videoHeight) {
throw new Error('Video dimensions are not available yet.');
}
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const context = canvas.getContext('2d');
context.drawImage(video, 0, 0, canvas.width, canvas.height);
canvas.toBlob((blob) => {
if (!blob) throw new Error('The browser could not encode the thumbnail.');
const url = URL.createObjectURL(blob);
preview.src = url;
download.href = url;
download.hidden = false;
}, 'image/jpeg', 0.88);
}, { once: true });
video.addEventListener('error', () => {
console.error('All video sources failed to load.', video.error);
});
</script>
</body>
</html>
loadedmetadata tells you that metadata such as dimensions is available. loadeddata indicates that the first frame is available to render. Use the latter when you intend to call drawImage().
2. Capture a chosen timestamp
For a representative frame, seek first and capture only after the browser fires seeked. The helper below rejects invalid times and avoids leaving event listeners attached.
function seekVideo(video, seconds) {
return new Promise((resolve, reject) => {
const onSeeked = () => {
cleanup();
resolve();
};
const onError = () => {
cleanup();
reject(video.error || new Error('Video seek failed'));
};
const cleanup = () => {
video.removeEventListener('seeked', onSeeked);
video.removeEventListener('error', onError);
};
video.addEventListener('seeked', onSeeked, { once: true });
video.addEventListener('error', onError, { once: true });
video.currentTime = Math.max(0, Math.min(seconds, video.duration || seconds));
});
}
async function thumbnailAt(video, seconds, type = 'image/jpeg', quality = 0.88) {
if (video.readyState < 2) {
await new Promise((resolve, reject) => {
video.addEventListener('loadeddata', resolve, { once: true });
video.addEventListener('error', () => reject(video.error), { once: true });
});
}
await seekVideo(video, seconds);
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
canvas.getContext('2d').drawImage(video, 0, 0, canvas.width, canvas.height);
return new Promise((resolve, reject) => {
canvas.toBlob(blob => blob ? resolve(blob) : reject(new Error('Encoding failed')), type, quality);
});
}
// Example:
const blob = await thumbnailAt(document.querySelector('video'), 12);
const imageUrl = URL.createObjectURL(blob);
document.querySelector('#preview').src = imageUrl;
If the requested time is beyond the duration, clamp it to a valid range. Duration can be unavailable or reported as Infinity for some streaming media; in that case choose a known safe time or wait for duration metadata.
3. A production-ready HTML example
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Timestamp thumbnail generator</title>
<style>
body { font: 16px system-ui, sans-serif; max-width: fiftyrem; margin: 2rem auto; padding: 0 1rem; }
video, img { width: 100%; height: auto; display: block; }
form { display: flex; gap: .5rem; margin: 1rem 0; }
</style>
</head>
<body>
<video id="video" controls preload="metadata" crossorigin="anonymous"
src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4">
</video>
<form id="form">
<label>Seconds <input id="seconds" type="number" min="0" step="0.1" value="1"></label>
<button>Generate thumbnail</button>
</form>
<p id="status" role="status">Loading video metadata…</p>
<img id="output" alt="Generated video thumbnail" hidden>
<a id="save" download="video-thumbnail.jpg" hidden>Save JPEG</a>
<script>
const video = document.querySelector('#video');
const form = document.querySelector('#form');
const seconds = document.querySelector('#seconds');
const status = document.querySelector('#status');
const output = document.querySelector('#output');
const save = document.querySelector('#save');
let previousUrl;
const ready = new Promise((resolve, reject) => {
video.addEventListener('loadedmetadata', resolve, { once: true });
video.addEventListener('error', () => reject(video.error), { once: true });
});
form.addEventListener('submit', async (event) => {
event.preventDefault();
try {
await ready;
if (!video.videoWidth || !video.videoHeight) throw new Error('No intrinsic video dimensions.');
const requested = Number(seconds.value);
if (!Number.isFinite(requested) || requested < 0) throw new Error('Enter a non-negative timestamp.');
const target = Number.isFinite(video.duration) ? Math.min(requested, video.duration) : requested;
status.textContent = 'Seeking…';
await seekVideo(video, target);
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
canvas.getContext('2d').drawImage(video, 0, 0, canvas.width, canvas.height);
const blob = await new Promise((resolve, reject) => canvas.toBlob(
value => value ? resolve(value) : reject(new Error('Could not encode image')), 'image/jpeg', 0.88));
if (previousUrl) URL.revokeObjectURL(previousUrl);
previousUrl = URL.createObjectURL(blob);
output.src = previousUrl;
output.hidden = false;
save.href = previousUrl;
save.hidden = false;
status.textContent = `Captured at ${target.toFixed(1)} seconds (${canvas.width}×${canvas.height}).`;
} catch (error) {
status.textContent = error.message || 'Thumbnail generation failed.';
}
});
function seekVideo(video, seconds) {
return new Promise((resolve, reject) => {
const done = () => { cleanup(); resolve(); };
const fail = () => { cleanup(); reject(video.error || new Error('Seek failed')); };
const cleanup = () => {
video.removeEventListener('seeked', done);
video.removeEventListener('error', fail);
};
video.addEventListener('seeked', done, { once: true });
video.addEventListener('error', fail, { once: true });
video.currentTime = seconds;
});
}
</script>
</body>
</html>
4. Canvas size, aspect ratio, and image quality
- Set
canvas.widthandcanvas.heighttovideo.videoWidthandvideo.videoHeightfor a full-resolution frame. These are intrinsic dimensions, not CSS dimensions. - For a smaller preview, preserve the aspect ratio:
targetHeight = Math.round(targetWidth * video.videoHeight / video.videoWidth). - CSS such as
width: 320pxchanges display size only; it does not reduce the canvas backing buffer or the exported file. - Use
toBlob()for normal uploads and downloads.toDataURL()is convenient for small inline previews but creates a large in-memory string. - JPEG is compact for photographic video frames. PNG preserves every pixel and supports lossless output. WebP can be useful where your target browsers and pipeline support it.
- To crop rather than stretch, calculate a source rectangle and use the nine-argument form of
drawImage().
5. Remote videos and CORS
For a cross-origin file, set crossorigin="anonymous" before the video request starts and configure the media server to return an appropriate Access-Control-Allow-Origin header. The attribute alone does not grant permission. Without server-approved CORS, the video may play but drawing it to canvas can produce a security error or a canvas that cannot be exported. See MDN’s video element documentation for the crossorigin behavior.
<video crossorigin="anonymous" src="https://cdn.example.com/movie.mp4"></video>
Configure the server response, for example, with Access-Control-Allow-Origin: https://your-site.example (or a deliberately broader value appropriate for your deployment). If you cannot change the media server, proxy the file through a server you control, subject to the video’s access rights.
6. Browser readiness and seeking details
| Signal | Use it for |
|---|---|
loadedmetadata |
Reading duration and intrinsic dimensions. |
loadeddata |
Knowing the first frame is available to render. |
seeked |
Knowing a currentTime change has completed. |
error |
Handling a failure after all video sources fail. |
Do not read videoWidth or videoHeight before metadata is available; they can be zero. Seeking can take longer for remote files, keyframe-heavy codecs, or slow connections. Disable the generate button while a capture is running if users can submit repeatedly.
7. First frame versus a selected frame
| Approach | Advantages | Trade-offs |
|---|---|---|
| First available frame | Simple and fast; no seek required. | May be black, a title card, or an unrepresentative opening. |
| Selected timestamp | Author controls composition and can avoid fades or blank openings. | Requires a completed seek and additional waiting. |
Use the first frame for quick previews and selected timestamps when the thumbnail is part of publishing, search, or a media library workflow.
8. Format support and multiple sources
Browsers do not support exactly the same video formats. Provide fallback <source> elements when your audience requires them, and listen for error after all sources fail.
<video id="video" preload="metadata" crossorigin="anonymous">
<source src="movie.webm" type="video/webm">
<source src="movie.mp4" type="video/mp4">
Your browser cannot play this video.
</video>
9. Troubleshooting checklist
| Symptom | Likely cause | Fix |
|---|---|---|
| Canvas is blank | Captured before frame data was ready. | Wait for loadeddata; verify video.readyState >= 2. |
| Canvas dimensions are 0×0 | Metadata has not loaded. | Wait for loadedmetadata and read videoWidth/videoHeight. |
SecurityError on export |
Cross-origin media lacks permitted CORS headers. | Set crossorigin before loading and configure Access-Control-Allow-Origin, or proxy the file. |
| Chosen timestamp is ignored | Capture runs before the seek completes. | Set currentTime, then wait for seeked. |
| Seek never finishes | Network interruption, unsupported media, or an invalid time. | Validate the source, clamp to duration, handle error, and show a timeout/retry state. |
| Thumbnail looks stretched | Canvas dimensions do not match the video’s aspect ratio. | Preserve the videoWidth/videoHeight ratio or crop deliberately. |
| File is too large | Full-resolution PNG or a large data URL. | Use toBlob(), JPEG/WebP, a smaller canvas, and an explicit quality value. |
| Works locally but not in production | Different origin, codec, headers, or HTTPS policy. | Inspect network responses, CORS headers, MIME types, and browser console errors. |
10. Performance, reliability, and cost
- Generate only when needed instead of decoding every video on page load. Use
preload="metadata"for libraries that initially need dimensions only. - Downscale before encoding when the thumbnail display is small; canvas memory grows with pixel count.
- Reuse one canvas for multiple timestamps and revoke old object URLs with
URL.revokeObjectURL(). - Queue captures when processing many videos so decoding and encoding do not block the interface.
- Keep the original video accessible and treat thumbnail generation as retryable. Network failures, unsupported codecs, and expired URLs are normal failure modes.
- Browser capture has no API request charge, but it consumes the user’s CPU, memory, bandwidth, and battery. Server-side generation may be preferable for batch jobs or consistent output.
11. Or skip the browser setup
ScreenshotNeo is a website screenshot API and MCP server. It can capture a rendered video page after your own page has selected or displayed the desired preview. Cookie banners, newsletter popups, and chat widgets are removed before the shot; bot checks, blank pages, failed loads, timeouts, and cache hits are not billed. Its MCP server lets Claude, Cursor, and other MCP clients use take_screenshot, get_page_info, and capture_pdf.
See the ScreenshotNeo API documentation for all options. A one-call capture looks like this:
curl -G "https://api.screenshotneo.com/v1/shot" \
-d access_key=YOUR_API_KEY \
--data-urlencode url=https://example.com/video-page \
-o shot.webp
import requests
r = requests.get(
"https://api.screenshotneo.com/v1/shot",
params={"access_key": "YOUR_API_KEY", "url": "https://example.com/video-page"},
timeout=90,
)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({
access_key: 'YOUR_API_KEY',
url: 'https://example.com/video-page'
});
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot failed: ${res.status}`);
const image = Buffer.from(await res.arrayBuffer());
ScreenshotNeo has 1,000 free screenshots per month with no card; paid plans start at $5 for 3,000 shots. Create a free ScreenshotNeo account.
12. FAQ
Can I capture a frame without showing video controls?
Yes. Controls affect presentation, not the pixels drawn from the video. Omit the controls attribute or hide the element while drawing.
Should I use toDataURL() or toBlob()?
Use toBlob() for uploads and larger images. Use toDataURL() when a small data URL is more convenient.
Why does adding crossorigin not solve the problem?
The media server must also return a compatible CORS response, and the attribute must be present before the request starts.
Can this work with live streams?
Sometimes, but duration and seeking may be unavailable. Capture a currently available frame after loadeddata and avoid assuming a finite timeline.
How do I create several thumbnails?
Reuse one video and canvas, seek to each timestamp sequentially, await seeked, export, and revoke each object URL when it is no longer needed.
13. Reference documentation
- MDN: HTML video element — readiness events, CORS, and source formats.
- MDN: HTMLMediaElement —
currentTime, duration, and media state. - MDN: seeked event — seek completion semantics.
- MDN: HTMLVideoElement — intrinsic dimensions.
- MDN: drawImage() and toDataURL() — drawing and export APIs.


