Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Use an HTML <video> element as the source for a canvas: seek to each timestamp, wait for the seek to finish, draw the displayed frame with drawImage(), then export the canvas as an image. Process timestamps one at a time so each image corresponds to its own seek. The complete example below produces downloadable PNGs and covers readiness, validation, cross-origin restrictions, and common failure cases.

How video-to-canvas screenshots work

A browser can draw a video frame into a canvas just as it can draw an image. The basic sequence is:

  1. Wait until the video has metadata and dimensions.
  2. Assign the target time to video.currentTime.
  3. Wait for the seeked event, which indicates that the seek has completed.
  4. Draw the video into a canvas using ctx.drawImage(video, 0, 0).
  5. Export the canvas as a Blob and present it for download or preview.

Setting currentTime requests a seek; it does not mean the requested frame is already ready to capture. The browser may seek to a nearby position supported by the media rather than an exact arbitrary frame. This is why the event wait and realistic expectations about timestamp precision matter. See MDN’s currentTime property reference and seeked event reference.

A complete working example

Save the following as an HTML file and open it in a browser. Choose a video file from your device, enter timestamps in seconds separated by commas, then select Capture frames. Each capture is shown with its timestamp and has its own PNG download link.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Capture video frames</title>
<style>
  body { font: 16px system-ui, sans-serif; max-width: 900px; margin: 2rem auto; padding: 0 1rem; }
  video { display: block; max-width: 100%; margin: 1rem 0; }
  .frame { display: inline-block; vertical-align: top; margin: 0.5rem; }
  .frame img { display: block; max-width: 260px; height: auto; }
</style>
<h1>Capture multiple video frames</h1>
<label>Video file: <input id="file" type="file" accept="video/*"></label>
<video id="video" controls preload="metadata"></video>
<label for="times">Times in seconds (comma-separated):</label>
<input id="times" value="1, 3, 5" size="35">
<button id="capture" disabled>Capture frames</button>
<p id="status" role="status">Choose a video file to begin.</p>
<div id="results"></div>
<canvas id="canvas" hidden></canvas>
<script>
const fileInput = document.querySelector('#file');
const video = document.querySelector('#video');
const timesInput = document.querySelector('#times');
const button = document.querySelector('#capture');
const status = document.querySelector('#status');
const results = document.querySelector('#results');
const canvas = document.querySelector('#canvas');
let videoUrl;
let activeObjectUrls = [];

function waitForEvent(target, eventName) {
  return new Promise((resolve, reject) => {
    const cleanup = () => {
      target.removeEventListener(eventName, onEvent);
      target.removeEventListener('error', onError);
    };
    const onEvent = (event) => { cleanup(); resolve(event); };
    const onError = () => {
      cleanup();
      reject(target.error || new Error('Video failed to load'));
    };
    target.addEventListener(eventName, onEvent, { once: true });
    target.addEventListener('error', onError, { once: true });
  });
}

async function ensureMetadata() {
  if (video.readyState >= HTMLMediaElement.HAVE_METADATA) return;
  await waitForEvent(video, 'loadedmetadata');
}

async function seekTo(seconds) {
  const done = waitForEvent(video, 'seeked');
  video.currentTime = seconds;
  // A seek may complete immediately if the requested position is already current.
  if (video.seeking) await done;
}

function canvasToBlob(canvas) {
  return new Promise((resolve, reject) => {
    canvas.toBlob((blob) => {
      if (blob) resolve(blob);
      else reject(new Error('Canvas image encoding failed'));
    }, 'image/png');
  });
}

async function captureAt(seconds) {
  await ensureMetadata();
  if (!Number.isFinite(seconds) || seconds < 0) {
    throw new Error(`Invalid timestamp: ${seconds}`);
  }
  if (Number.isFinite(video.duration) && seconds > video.duration) {
    throw new Error(`${seconds}s is beyond the video duration (${video.duration.toFixed(2)}s)`);
  }
  if (!video.videoWidth || !video.videoHeight) {
    throw new Error('Video dimensions are not available');
  }

  await seekTo(seconds);
  // Wait for a presented video frame when the browser supports this API.
  if ('requestVideoFrameCallback' in video) {
    await new Promise((resolve) => video.requestVideoFrameCallback(resolve));
  }

  canvas.width = video.videoWidth;
  canvas.height = video.videoHeight;
  const ctx = canvas.getContext('2d');
  if (!ctx) throw new Error('Canvas 2D context is unavailable');
  ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
  return await canvasToBlob(canvas);
}

fileInput.addEventListener('change', () => {
  if (videoUrl) URL.revokeObjectURL(videoUrl);
  const file = fileInput.files[0];
  if (!file) return;
  videoUrl = URL.createObjectURL(file);
  video.src = videoUrl;
  video.load();
  button.disabled = false;
  status.textContent = 'Video selected. Enter timestamps and capture.';
});

button.addEventListener('click', async () => {
  button.disabled = true;
  status.textContent = 'Capturing frames…';
  results.replaceChildren();
  for (const url of activeObjectUrls) URL.revokeObjectURL(url);
  activeObjectUrls = [];

  try {
    const times = timesInput.value.split(',').map((part) => Number(part.trim()));
    if (!times.length || times.some((time) => !Number.isFinite(time))) {
      throw new Error('Enter one or more valid numeric timestamps separated by commas.');
    }
    for (const seconds of times) {
      const blob = await captureAt(seconds);
      const url = URL.createObjectURL(blob);
      activeObjectUrls.push(url);
      const card = document.createElement('div');
      card.className = 'frame';
      const label = document.createElement('p');
      label.textContent = `Frame at ${seconds}s`;
      const image = document.createElement('img');
      image.src = url;
      image.alt = `Video frame at ${seconds} seconds`;
      const link = document.createElement('a');
      link.href = url;
      link.download = `frame-${seconds}s.png`;
      link.textContent = 'Download PNG';
      card.append(label, image, link);
      results.append(card);
    }
    status.textContent = `Captured ${times.length} frame(s).`;
  } catch (error) {
    status.textContent = error.message || 'Capture failed.';
  } finally {
    button.disabled = false;
  }
});
</script>
</html>

This example uses a locally selected file, so it avoids a remote host’s CORS policy. It deliberately captures sequentially: a new seek does not begin until the previous frame has been exported. For a page that already has a video element, keep the helper functions and pass that element to the capture routine instead of loading a local file.

Validate times and handle media timelines

Ordinary finite videos

For a finite video with a known duration, reject negative timestamps and values beyond video.duration. Metadata must be available before duration and intrinsic dimensions can be relied on. Set the canvas size from video.videoWidth and video.videoHeight; these are the media’s intrinsic pixel dimensions. If you intentionally want smaller output, set a smaller canvas and draw into that target size.

Seeking is not always frame-exact

currentTime is a floating-point position in seconds, but codecs and browser seeking behavior can constrain which frame is decoded at an arbitrary point. Treat the supplied timestamp as the requested seek target, not a guarantee that every browser will return the exact frame at that fractional instant. Applications requiring strict frame-level matching should test their actual codecs, files, and target browsers.

Live streams and nonzero timeline starts

Some media timelines do not start at zero. Live streams may have an unknown duration, and older segments can fall out of the available seekable window. Inspect video.seekable before requesting a point when working with such media, and handle a target outside the current seekable ranges rather than assuming every nonnegative value is valid. The current time and seekable timeline are described in MDN’s currentTime documentation.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Frame readiness and browser support

The seeked event is the key signal that the seek operation has finished. MDN describes it as firing after the seek completes, the playback position changes, and seeking becomes false. requestVideoFrameCallback() can provide a frame-aware hook after a frame is sent for composition, but it is not a strict synchronization guarantee. Detect it before use, as in the example.

The MDN page reviewed on September 30, 2026 labels requestVideoFrameCallback() Baseline 2024 and notes that older browsers and devices may not support it. The broadly supported fallback is to await seeked, ensure the current frame is available, then draw and test the behavior on the browsers and media you support. readyState distinguishes metadata availability from current-frame data availability. The loadeddata event often indicates that the frame at the current position has loaded, but MDN notes that it may not fire on mobile or tablet devices when data saver is enabled; do not make it your only readiness mechanism. See MDN’s readyState reference and requestVideoFrameCallback reference.

Cross-origin video and canvas security

A video from another origin can sometimes play in a page yet still be unavailable for canvas export. Drawing cross-origin pixels without CORS approval taints the canvas. After that, operations such as canvas.toBlob(), canvas.toDataURL(), or pixel reads can throw a SecurityError.

  1. Set video.crossOrigin = 'anonymous' (or the crossorigin="anonymous" attribute) before assigning the video source or starting its load.
  2. Make sure the video server returns an appropriate Access-Control-Allow-Origin header for your page’s origin.
  3. If credentials are specifically needed, configure the credentialed CORS mode and server policy accordingly; anonymous access is the usual starting point.
  4. If you do not control the video host, ask it to enable CORS or use an authorized same-origin proxy. Client-side JavaScript cannot override the host’s CORS policy.

See MDN’s cross-origin canvas guide and crossOrigin property reference.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Export, display, and manage multiple images

canvas.toBlob() is a practical export method for downloadable image files; the example creates an object URL for display and download. Revoke each URL with URL.revokeObjectURL() when it is no longer needed. For small inline previews, toDataURL() is available, but it creates an encoded string in memory and is less suitable for accumulating many large frames.

For a gallery, keep each image with its source timestamp, provide a clear download action, and impose a reasonable capture limit. Full-resolution images and their object URLs consume memory while retained. Release URLs when replacing a gallery or leaving the page. These are resource-management practices; actual memory use depends on frame dimensions, browser, and the number of retained images.

Troubleshooting

  • The output shows the old frame. The draw happened before the seek completed, or before the frame was ready. Await seeked, capture requests serially, and use requestVideoFrameCallback() where supported.
  • The seek does not reach the requested time. The media may not support that exact position, the time may be outside a seekable range, or the stream timeline may have shifted. Check duration and seekable ranges, then choose an available time.
  • videoWidth and videoHeight are zero. Metadata has not loaded or the media failed. Wait for loadedmetadata and inspect the video element’s error state.
  • toBlob() throws SecurityError. The canvas is tainted by cross-origin pixels. Configure CORS before loading and ensure the server grants access; a browser-side attribute alone is not sufficient.
  • The video plays but capture fails. Playback permission and canvas readback permission are separate concerns. Check the console and the source’s CORS response headers.
  • No frame appears on a mobile device. A data-saver setting may prevent loadeddata from firing. Use readiness-state checks and test the seek-and-draw flow on the device rather than treating that event as universal.
  • Encoding returns no image. Check that a 2D context exists, the canvas has nonzero dimensions, and the source frame is available before calling toBlob().
  • One capture has another timestamp’s frame. Multiple overlapping currentTime assignments can race. Keep a single capture queue or serial loop and wait for each seek before starting the next.
Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Or skip the browser setup

If you need screenshots of web pages rather than frames from a video timeline, ScreenshotNeo is a website screenshot API and MCP server. It does not replace the video-element workflow above: its screenshot call captures a page, not a set of video timestamps. A one-request example for a page is:

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp

See the ScreenshotNeo API documentation for request options. It accepts cookie or consent banners and removes more than 60 known consent platforms, newsletter popups, and chat widgets before the shot; these steps can each be turned off. Bot checks or CAPTCHAs, blank pages, timeouts, failed loads, and cache hits are not billed, and response headers report the page verdict and billing status. Its MCP server provides take_screenshot, get_page_info, and capture_pdf for AI agents. The Free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000 shots. All features are available on every plan.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Sign up for ScreenshotNeo’s free plan: 1,000 screenshots a month, no card required.

Frequently Asked Questions

Can I save each captured frame as JPEG or WebP instead of PNG?

Yes. Pass a supported MIME type such as image/jpeg or image/webp as the second argument to canvas.toBlob(), and use a matching filename extension. Browser support and encoding quality options vary by format.

Can I capture screenshots from a video embedded on another website?

Only if the video is accessible to your page and its host permits canvas use through CORS. A video that can be played is not necessarily exportable; without the server’s CORS approval, canvas export is blocked.

Does the method capture the video controls or player interface?

No. Drawing the video element captures its video pixels, not the surrounding HTML controls, captions rendered elsewhere, or player chrome. Capturing those requires a different page-screenshot approach.

What’s actually slowing this PC down?

Pick the symptom - the matching free tool is one click away.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.