ScreenshotNeo

BlogHow-to

How to Capture Video Frames to a Canvas with JavaScript

Learn how to seek, draw, crop, and process HTML video frames on a canvas with JavaScript, including requestVideoFrameCallback and fallbacks.

By the ScreenshotNeo team1 October 20268 min read

To capture a video frame, pass the HTMLVideoElement to the canvas 2D context’s drawImage() method. For a frame at a specific timestamp, seek the video, wait for the seek and media data to become ready, then draw. For continuous frame-by-frame work, use requestVideoFrameCallback() so processing follows video frames.

drawImage() accepts an HTMLVideoElement and supports full-frame drawing, scaling, and cropping. The video must have usable current data before you draw; for video, that generally means readyState > 1 (HAVE_CURRENT_DATA). See the MDN drawImage documentation and MDN’s canvas image tutorial.

Basic still-frame capture

This complete page captures the video’s current frame when you click a button. It waits for metadata before sizing the canvas and refuses to draw until media data is available.

<!doctype html>
<html lang="en">
<meta charset="utf-8">
<title>Video frame to canvas</title>
<style>
  video, canvas { max-width: 100%; display: block; margin-block: 1rem; }
</style>
<video id="video" controls muted playsinline src="clip.mp4"></video>
<button id="capture" type="button">Capture current frame</button>
<canvas id="canvas"></canvas>
<a id="download" hidden download="frame.png">Download PNG</a>
<script>
const video = document.querySelector('#video');
const canvas = document.querySelector('#canvas');
const ctx = canvas.getContext('2d');
const capture = document.querySelector('#capture');
const download = document.querySelector('#download');

video.addEventListener('loadedmetadata', () => {
  canvas.width = video.videoWidth;
  canvas.height = video.videoHeight;
});

function drawCurrentFrame() {
  if (video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) {
    throw new Error('The current video frame is not ready');
  }
  ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
  download.href = canvas.toDataURL('image/png');
  download.hidden = false;
}

capture.addEventListener('click', () => {
  try {
    drawCurrentFrame();
  } catch (error) {
    console.error(error);
  }
});
</script>
</html>

Why the canvas dimensions matter

Set the canvas’s width and height attributes, or the equivalent JavaScript properties, to the intended backing resolution. CSS resizing changes only display size and can make an otherwise sharp capture look blurry. Use video.videoWidth and video.videoHeight after loadedmetadata when you want the source’s intrinsic dimensions.

Capture a frame at a timestamp

Assign currentTime, pause if necessary, and draw only after the seek has completed and the target frame is available. A one-shot seeked listener is simple, but repeated or overlapping seeks need coordination.

const video = document.querySelector('#video');
const canvas = document.querySelector('#canvas');
const ctx = canvas.getContext('2d');

function waitForEvent(target, name) {
  return new Promise((resolve, reject) => {
    const onEvent = () => { cleanup(); resolve(); };
    const onError = () => { cleanup(); reject(new Error(`Media error while waiting for ${name}`)); };
    const cleanup = () => {
      target.removeEventListener(name, onEvent);
      target.removeEventListener('error', onError);
    };
    target.addEventListener(name, onEvent, { once: true });
    target.addEventListener('error', onError, { once: true });
  });
}

async function captureAt(seconds) {
  if (!Number.isFinite(seconds) || seconds < 0) {
    throw new RangeError('seconds must be a non-negative finite number');
  }
  if (video.readyState < HTMLMediaElement.HAVE_METADATA) {
    await waitForEvent(video, 'loadedmetadata');
  }
  const target = Math.min(seconds, video.duration || seconds);
  video.pause();
  video.currentTime = target;
  await waitForEvent(video, 'seeked');

  if (video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) {
    await waitForEvent(video, 'canplay');
  }
  ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
  return canvas.toDataURL('image/png');
}

captureAt(12.5).then(dataUrl => {
  document.querySelector('#download').href = dataUrl;
});

Handling repeated seeks safely

If a user scrubs a timeline rapidly, a new seek can supersede an old one. Track a request ID and ignore stale completions, or serialize captures in a queue.

let requestId = 0;

async function captureLatest(seconds) {
  const id = ++requestId;
  video.currentTime = seconds;
  await waitForEvent(video, 'seeked');
  if (id !== requestId) return null;
  if (video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) {
    await waitForEvent(video, 'canplay');
  }
  ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
  return canvas.toBlob(blob => blob, 'image/png');
}

Continuous capture with requestVideoFrameCallback()

requestVideoFrameCallback() registers work when a new video frame is sent to the compositor. Its cadence is bounded by the lower of the video’s frame rate and the browser’s paint refresh rate. It can run a vertical-sync late, so it is frame-oriented rather than a hard real-time synchronization guarantee. Consult the MDN API documentation.

const video = document.querySelector('#video');
const canvas = document.querySelector('#canvas');
const ctx = canvas.getContext('2d');

function onVideoFrame(now, metadata) {
  if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
    ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
    // metadata.mediaTime identifies the media timestamp for this callback.
    // Send the canvas to a worker, encoder, or analysis pipeline here.
  }
  video.requestVideoFrameCallback(onVideoFrame);
}

if ('requestVideoFrameCallback' in HTMLVideoElement.prototype) {
  video.requestVideoFrameCallback(onVideoFrame);
} else {
  // Use a requestAnimationFrame or timer fallback only for browsers you support.
  console.warn('requestVideoFrameCallback is unavailable');
}

Feature-detect this API. MDN labels it Baseline 2024 and says it is available on latest devices and browser versions since October 2024, while older devices and versions may not support it.

Resize, crop, and preserve aspect ratio

The eight-argument form selects a source rectangle and writes it to a destination rectangle:

// drawImage(source, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight)
ctx.drawImage(video, 100, 50, 640, 360, 0, 0, 1280, 720);

The four-argument form scales the complete frame:

// drawImage(source, dx, dy, dWidth, dHeight)
ctx.drawImage(video, 0, 0, 640, 360);

To fit without distortion, calculate a destination rectangle that preserves the source aspect ratio. To fill a fixed box, crop the excess area before drawing. For a high-density display, multiply the backing dimensions by devicePixelRatio, then scale the context:

function sizeCanvasForCss(canvas, cssWidth, cssHeight) {
  const ratio = window.devicePixelRatio || 1;
  canvas.width = Math.round(cssWidth * ratio);
  canvas.height = Math.round(cssHeight * ratio);
  canvas.style.width = `${cssWidth}px`;
  canvas.style.height = `${cssHeight}px`;
  canvas.getContext('2d').setTransform(ratio, 0, 0, ratio, 0, 0);
}

Export the captured frame

Use toDataURL() for a small, immediate result or toBlob() for a more memory-efficient asynchronous export.

canvas.toBlob(blob => {
  if (!blob) throw new Error('The canvas could not be encoded');
  const url = URL.createObjectURL(blob);
  const link = document.createElement('a');
  link.href = url;
  link.download = 'video-frame.webp';
  link.click();
  URL.revokeObjectURL(url);
}, 'image/webp', 0.9);

Cross-origin video and tainted canvases

If the video is hosted on another origin, the server must permit your page’s origin with CORS headers. Set the video’s crossOrigin property before assigning src:

const video = document.createElement('video');
video.crossOrigin = 'anonymous';
video.src = 'https://cdn.example.com/clip.mp4';
video.muted = true;
video.playsInline = true;
video.load();

If CORS is not permitted, the browser may still display the video but taint the canvas. Calls such as toDataURL(), toBlob(), or getImageData() then fail with a security error. You cannot fix that restriction in client-side JavaScript; configure the media server or proxy the asset through a server you control.

Browser readiness and playback edge cases

  • Metadata not loaded: wait for loadedmetadata before reading dimensions or seeking.
  • No current frame: check readyState >= HAVE_CURRENT_DATA and wait for canplay or a video-frame callback.
  • Seeking beyond duration: clamp the requested time to video.duration after metadata is available.
  • Autoplay restrictions: use muted and playsinline where playback is needed; a user gesture may still be required.
  • Live streams: duration may be infinite, and seeking may be unavailable. Capture the current frame instead.
  • Source changes: after changing src, call load() and wait for new metadata.
  • Orientation: some camera videos expose dimensions that change after metadata or track updates; size the canvas again when needed.

Advanced processing with WebCodecs

WebCodecs can expose decoded VideoFrame objects for pipelines that need direct frame processing. A VideoFrame can be drawn using a canvas rendering method, but browser Canvas 2D implementations vary and may perform inconsistently. Check support and performance in every target browser before making it your default path. See MDN’s WebCodecs guide and VideoFrame reference.

Performance and reliability checklist

  • Draw at the output resolution you actually need; very large canvases increase memory and encoding time.
  • Prefer toBlob() over large data URLs for uploads.
  • Throttle expensive analysis or encoding if every video frame is not required.
  • Reuse one canvas and one context instead of allocating per frame.
  • Move CPU-heavy pixel work to an OffscreenCanvas worker when your browser targets support it.
  • Cancel or ignore stale seek requests when users scrub quickly.
  • Release object URLs with URL.revokeObjectURL().
  • Handle media error, stalled loads, unsupported codecs, and CORS failures explicitly.

Or skip the browser setup

If you need a rendered screenshot of a video page rather than client-side frame processing, ScreenshotNeo provides a single website screenshot API request. Its browser handles the page, and you can request PNG, JPEG, WebP, or PDF output. See the ScreenshotNeo API documentation.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

Cookie banners, newsletter popups, and chat widgets are removed before the shot. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and response headers identify the page verdict and billing result. An MCP server provides take_screenshot, get_page_info, and capture_pdf tools for Claude, Cursor, and other MCP clients. The free plan includes 1,000 screenshots each month with no card; paid plans start at $5 for 3,000 shots. Create a free ScreenshotNeo account.

Troubleshooting

Symptom Likely cause Fix
Canvas is blank The video has no current media data. Wait for loadedmetadata, canplay, or requestVideoFrameCallback(); check readyState.
Wrong dimensions Canvas was sized before metadata or only resized with CSS. Set canvas.width and canvas.height from intrinsic video dimensions.
Frame is from the wrong time Drawing happened before the seek completed. Wait for seeked and confirm current data is available.
SecurityError on export A cross-origin video tainted the canvas. Enable CORS on the media host and set crossOrigin before src.
requestVideoFrameCallback is undefined The browser is older or unsupported. Feature-detect and provide a target-browser fallback.
Video will not autoplay Browser autoplay policy blocked sound or playback. Mute the video, use playsinline, and start playback from a user gesture.
Capture fails after changing source Old metadata or listeners are still in use. Call load(), wait for fresh metadata, and reset pending seek state.

FAQ

Can I capture a frame without playing the video?

Yes. Load the video, set currentTime, wait for seeked and frame readiness, then call drawImage().

Does requestAnimationFrame capture every video frame?

No. It follows display refresh. Use requestVideoFrameCallback() when work should be driven by delivered video frames.

Can I capture a frame from a protected stream?

Encrypted or otherwise protected media may prevent frame extraction. Browser canvas APIs cannot bypass those restrictions.

What image format should I export?

PNG preserves lossless detail, JPEG is smaller for photographic frames, and WebP often provides a useful size-quality balance. Choose based on your upload and display requirements.

Can I draw only part of a frame?

Yes. Use the eight-argument drawImage() form with source coordinates and dimensions for cropping.