ScreenshotNeo

BlogHow-to

How to Extract a Thumbnail from an MP4 Video with JavaScript

Capture any MP4 frame in the browser with video, canvas, seeking, CORS handling, scaling, and downloadable Blob output.

By the ScreenshotNeo team1 October 20267 min read

To extract a thumbnail from an MP4 video with JavaScript, load the file into an HTMLVideoElement, wait for metadata, seek to the desired timestamp, wait for the seeked event, draw the decoded frame onto a canvas, and export the canvas as a JPEG, PNG, WebP, Blob, or data URL.

The seek must finish before drawing. Reading the canvas too early can capture the previous frame or a blank image. Cross-origin videos also need CORS permission before their pixels can be exported.

Complete browser implementation

This function returns both a Blob and a data URL. It clamps the requested timestamp to the video duration and scales large videos down to a practical thumbnail width.

function extractThumbnail(videoUrl, {
  time = 0,
  type = 'image/jpeg',
  quality = 0.85,
  maxWidth = 640,
} = {}) {
  return new Promise((resolve, reject) => {
    const video = document.createElement('video');
    video.preload = 'metadata';
    // Set this before src for cross-origin videos that allow CORS.
    video.crossOrigin = 'anonymous';

    const fail = () => reject(video.error || new Error('Unable to load video'));
    video.addEventListener('error', fail, { once: true });

    video.addEventListener('loadedmetadata', () => {
      if (!Number.isFinite(video.duration) || video.duration <= 0) {
        reject(new Error('Video has no usable duration'));
        return;
      }

      const captureTime = Math.min(
        Math.max(0, time),
        Math.max(0, video.duration - 0.001)
      );

      video.addEventListener('seeked', () => {
        const scale = Math.min(1, maxWidth / video.videoWidth);
        const canvas = document.createElement('canvas');
        canvas.width = Math.max(1, Math.round(video.videoWidth * scale));
        canvas.height = Math.max(1, Math.round(video.videoHeight * scale));

        const ctx = canvas.getContext('2d');
        if (!ctx) {
          reject(new Error('Canvas 2D context is unavailable'));
          return;
        }

        ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
        canvas.toBlob(blob => {
          if (!blob) {
            reject(new Error('Could not encode thumbnail'));
            return;
          }
          resolve({
            blob,
            dataUrl: canvas.toDataURL(type, quality),
          });
        }, type, quality);
      }, { once: true });

      video.currentTime = captureTime;
    }, { once: true });

    video.src = videoUrl;
    video.load();
  });
}

Example usage:

const result = await extractThumbnail('/videos/demo.mp4', {
  time: 12.5,
  type: 'image/jpeg',
  quality: 0.9,
  maxWidth: 800,
});

document.querySelector('#preview').src = result.dataUrl;

const downloadUrl = URL.createObjectURL(result.blob);
const link = document.createElement('a');
link.href = downloadUrl;
link.download = 'demo-thumbnail.jpg';
link.click();
URL.revokeObjectURL(downloadUrl);

MDN documents that drawImage() works correctly with an HTML video element after the media is ready and seeking has completed. See drawImage(), loadedmetadata, and seeked.

How the capture pipeline works

  1. Create a video element. It can be visible in the document or detached from it.
  2. Set preload = 'metadata' so duration and dimensions become available without eagerly downloading the entire file.
  3. Set crossOrigin = 'anonymous' before assigning src when the MP4 is hosted on another origin.
  4. Wait for loadedmetadata. Only then are duration, videoWidth, and videoHeight reliable.
  5. Clamp the requested time to the valid range and assign video.currentTime.
  6. Wait for seeked. The browser may need to fetch and decode data before the target frame is ready.
  7. Draw the frame with ctx.drawImage(video, ...).
  8. Export with toBlob() for files or uploads, or toDataURL() when an immediate image source is convenient.

Choosing the timestamp

A timestamp of 0 requests the first frame, but the first frame can be black, contain a fade-in, or be unavailable at an exact keyframe. A small offset such as 0.1 seconds often produces a more useful result. For a user-selected frame, pass the range input value as time:

timeSlider.addEventListener('input', async () => {
  const { dataUrl } = await extractThumbnail(videoFileUrl, {
    time: Number(timeSlider.value),
    maxWidth: 640,
  });
  preview.src = dataUrl;
});

Always clamp the time. The implementation above uses the duration minus a tiny safety margin so seeking exactly to the end does not fail on files whose final timestamp is slightly earlier than their reported duration.

Output formats and dimensions

Output Use it when Notes
JPEG Photos or video stills Small files; quality is usually between 0.75 and 0.95.
PNG Lossless output or graphics Larger files; transparency is not created from ordinary video pixels.
WebP Modern web delivery Use when your target browsers and image pipeline support it.
Blob Uploads, downloads, or object URLs Avoids embedding a large base64 string in HTML or JSON.
Data URL Immediate <img src> assignment Convenient, but base64 increases the in-memory representation.

toDataURL() defaults to PNG when no MIME type is provided. Both toDataURL() and toBlob() accept a MIME type and, for lossy formats, a quality value. See toDataURL() and toBlob().

Scaling before drawing reduces encoding time and memory use. Preserve the source aspect ratio by calculating both dimensions from one scale factor, as the example does. If you need an exact output box, draw into a fixed canvas and choose whether to crop, letterbox, or distort.

Local files, same-origin URLs, and CORS

Local file input

fileInput.addEventListener('change', async () => {
  const file = fileInput.files[0];
  if (!file) return;

  const objectUrl = URL.createObjectURL(file);
  try {
    const { dataUrl } = await extractThumbnail(objectUrl, { time: 2 });
    preview.src = dataUrl;
  } finally {
    URL.revokeObjectURL(objectUrl);
  }
});

Keep the object URL alive until loading and extraction finish. Revoke it afterward. If you retain the resulting Blob, revoking the source URL does not invalidate the Blob.

Same-origin MP4

A video served from the same origin as the page does not need CORS configuration for canvas export. Normal authentication rules still apply; a protected URL must be reachable by the browser session.

Cross-origin MP4

Set crossOrigin before src, and configure the video server to return an appropriate Access-Control-Allow-Origin header. If permission is missing, the browser marks the canvas as tainted and export throws a SecurityError. See MDN’s CORS-enabled images guidance, which applies to canvas pixel access.

Codec and browser support

The browser must decode the MP4’s contained video codec. The .mp4 extension alone does not guarantee that every browser can play the file. For an embedded player, provide alternate sources and let the browser select a supported one:

<video id='player' controls>
  <source src='/video.av1.mp4' type='video/mp4; codecs="av01.0.05M.08"'>
  <source src='/video-h264.mp4' type='video/mp4; codecs="avc1.42E01E"'>
</video>

For a programmatic extractor, catch the video error event and show a useful message. An unsupported codec, 404 response, authorization failure, or malformed file can all surface as a load error.

Common errors and fixes

Error or symptom Cause Fix
Blank or old frame Canvas was drawn before seeking completed. Draw inside a one-time seeked handler.
InvalidStateError Metadata or dimensions are not ready. Wait for loadedmetadata and verify videoWidth > 0.
SecurityError during export Cross-origin video tainted the canvas. Enable CORS on the video response and set crossOrigin before src.
Duration is Infinity or zero Metadata is incomplete, or the media is a stream. Wait for metadata; a non-seekable stream may not support arbitrary frame extraction.
Video error event Network failure, 404, authorization, or unsupported codec. Check the response in developer tools and provide a compatible MP4 source.
Thumbnail is rotated incorrectly Rotation metadata is handled differently by browsers or encoders. Test the target browsers and, when necessary, normalize orientation in a server-side media pipeline.
Capture hangs The requested seek never resolves because loading stalled. Set an application timeout, abort the operation, and report the network or codec problem.

Reliability and performance checklist

  • Use one video element per active extraction and remove listeners with { once: true }.
  • Set a timeout around metadata loading and seeking so a broken URL cannot hold a request forever.
  • Use toBlob() for uploads and downloads; reserve data URLs for small previews.
  • Limit maxWidth to the display size instead of encoding a full 4K frame for a 320-pixel card.
  • Reuse a canvas when extracting many frames sequentially to reduce allocations.
  • Do not assume a frame exists at every timestamp. Variable frame rate and keyframe seeking can produce a nearby decoded frame.
  • For many videos, queue work and limit concurrency so decoding and memory usage do not overwhelm the tab.
  • Keep user-selected files local when possible; upload only the resulting thumbnail if the application does not need the original video.

Or skip the browser setup

If what you need is a screenshot of a page that contains a video, ScreenshotNeo can return a page image with one GET request. It is a webpage screenshot API, so use the browser method above when you need an exact decoded MP4 frame; use ScreenshotNeo when the thumbnail should represent the rendered page.

See the ScreenshotNeo API documentation for all options.

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 accepts consent banners before capture and removes more than 60 known consent platforms, newsletter popups, and chat widgets. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and response headers identify the page verdict and billing result. Its MCP server lets Claude, Cursor, and other MCP clients call take_screenshot, get_page_info, and capture_pdf. The free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000 screenshots.

Create a free ScreenshotNeo account to try it with 1,000 screenshots a month and no card.

FAQ

Can I extract a thumbnail without displaying the video?

Yes. A detached video element works as long as the browser can load and decode the file and the canvas has permission to read its pixels.

Should I use a data URL or Blob?

Use a Blob for uploads, downloads, and object URLs. Use a data URL for a small, immediate image preview.

Why does the thumbnail differ slightly from the requested time?

Seeking is performed against the media’s available indexes and decoded frames, especially with variable frame rate files. The returned image is the frame the browser makes available after seeking.

Can this extract frames from a live stream?

Not reliably. Live or non-seekable media may have no finite duration and may not support seeking to an arbitrary timestamp.

When should extraction move to a server?

Use a server-side media pipeline when the browser cannot access the source because of CORS, when you need consistent codec behavior across clients, or when you must process large batches without using a user’s CPU and memory.