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

Some links on this page are affiliate links: if you buy through them we may earn a commission, at no extra cost to you.

Use an HTML <video> element as a canvas image source. For each timestamp, assign video.currentTime, wait for the seek to finish, draw the video frame into a canvas, then export it with canvas.toBlob(). Process timestamps one at a time: setting currentTime starts a seek but does not guarantee that the requested frame is already ready.

How the capture process works

A browser can draw the current video frame into a 2D canvas with drawImage(). To capture several points in a video, repeat this sequence for each time: validate the time, seek, wait for the seek to complete, draw the frame, and encode the canvas as an image.

  1. Wait until video metadata is available so dimensions and duration can be read.
  2. Assign a requested time in seconds to video.currentTime.
  3. Wait for the seeked event before drawing.
  4. Call ctx.drawImage(video, 0, 0, canvas.width, canvas.height).
  5. Export the canvas, preferably as a Blob using toBlob().

MDN describes currentTime as the media’s current playback time in seconds; assigning it requests a seek. The seek may be constrained by available timeline data, and the resulting position is not necessarily an exact arbitrary frame. See MDN’s currentTime reference.

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

The seeked event fires when a seek operation completes and the current playback position changes. It is the basic synchronization point for this workflow; it does not promise frame-accurate seeking for every codec, browser, or stream. See MDN’s seeked event reference.

Runnable example: capture multiple timestamps

This example takes a list of times, seeks serially, and displays downloadable PNGs in a gallery. It assumes the page already contains the video element below and that its source can be read by canvas under the same-origin or CORS rules discussed later.

<video id="video" controls preload="metadata" src="/media/demo.mp4"></video>
<canvas id="canvas" hidden></canvas>
<div id="frames"></div>

<script>
function waitForEvent(target, eventName, timeoutMs = 15000) {
  return new Promise((resolve, reject) => {
    const cleanup = () => {
      clearTimeout(timer);
      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"));
    };
    const timer = setTimeout(() => {
      cleanup();
      reject(new Error(`Timed out waiting for ${eventName}`));
    }, timeoutMs);
    target.addEventListener(eventName, onEvent, { once: true });
    target.addEventListener("error", onError, { once: true });
  });
}

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

function validateTime(video, seconds) {
  if (!Number.isFinite(seconds) || seconds < 0) {
    throw new RangeError("Timestamp must be a finite, non-negative number of seconds");
  }
  if (Number.isFinite(video.duration) && seconds > video.duration) {
    throw new RangeError(`Timestamp ${seconds}s is beyond the video duration ${video.duration}s`);
  }
  if (video.seekable.length) {
    let available = false;
    for (let i = 0; i < video.seekable.length; i++) {
      if (seconds >= video.seekable.start(i) && seconds <= video.seekable.end(i)) {
        available = true;
        break;
      }
    }
    if (!available) throw new RangeError(`Timestamp ${seconds}s is outside the seekable ranges`);
  }
}

async function captureAt(video, canvas, seconds) {
  await ensureMetadata(video);
  validateTime(video, seconds);

  const ctx = canvas.getContext("2d");
  if (!ctx) throw new Error("Canvas 2D context is unavailable");

  canvas.width = video.videoWidth;
  canvas.height = video.videoHeight;
  if (!canvas.width || !canvas.height) {
    throw new Error("Video dimensions are not available");
  }

  // Install the listener before assigning currentTime so a quick seek is not missed.
  const seekFinished = waitForEvent(video, "seeked");
  video.currentTime = seconds;
  if (video.seeking) await seekFinished;
  else {
    // The requested position may already be current; no seeked event is then required.
    // Avoid leaving a pending listener if no event will arrive.
    video.removeEventListener("seeked", () => {});
  }

  // If supported, wait for a frame-aware callback before drawing.
  if ("requestVideoFrameCallback" in video) {
    await new Promise((resolve) => video.requestVideoFrameCallback(resolve));
  }

  ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
  return new Promise((resolve, reject) => {
    canvas.toBlob((blob) => {
      if (blob) resolve(blob);
      else reject(new Error("Canvas image encoding failed"));
    }, "image/png");
  });
}

async function captureMany(video, canvas, timesInSeconds) {
  const results = [];
  for (const seconds of timesInSeconds) {
    const blob = await captureAt(video, canvas, seconds);
    results.push({ seconds, blob });
  }
  return results;
}

const video = document.querySelector("#video");
const canvas = document.querySelector("#canvas");
const gallery = document.querySelector("#frames");

captureMany(video, canvas, [1, 5.5, 12])
  .then((results) => {
    for (const { seconds, blob } of results) {
      const url = URL.createObjectURL(blob);
      const figure = document.createElement("figure");
      const image = document.createElement("img");
      const link = document.createElement("a");
      const caption = document.createElement("figcaption");
      image.src = url;
      image.alt = `Video frame at ${seconds} seconds`;
      link.href = url;
      link.download = `frame-${seconds}.png`;
      link.textContent = "Download PNG";
      caption.textContent = `Frame at ${seconds}s`;
      figure.append(image, caption, link);
      gallery.append(figure);
    }
  })
  .catch((error) => console.error("Frame capture failed:", error));
</script>

The event waiter includes a timeout and media error handling so an unavailable seek does not leave the operation waiting forever. The no-seek path can occur when the playhead is already at the requested position. In production code, manage event listeners explicitly if your application allows concurrent operations on the same video element; this example is designed for a single serial capture loop.

For strict cleanup, use an event-wait helper that can cancel its listener when the seek does not occur. Do not fire multiple currentTime assignments concurrently: a simple seek listener can otherwise resolve against the wrong requested timestamp.

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

Wait for data and choose output dimensions

Metadata versus frame data

loadedmetadata means the browser has metadata such as duration and dimensions. The media readiness states distinguish this from HAVE_CURRENT_DATA, which indicates that data for the current position is available. The loadeddata event often signals that the current frame has loaded, but MDN notes it may not fire on mobile or tablet devices when data saver is enabled. Do not make that event your only readiness mechanism. See MDN’s readyState reference.

Native resolution and scaling

Set the canvas width and height from video.videoWidth and video.videoHeight after metadata is ready. These are the video’s intrinsic dimensions. If you deliberately want a smaller thumbnail or a different aspect ratio, set canvas dimensions to your desired output size and draw into those dimensions; that scales the source frame and may change its proportions if the ratios differ.

Frame callback support

requestVideoFrameCallback() can provide a frame-aware hook before drawing. MDN labels it Baseline 2024 on the reviewed page and notes availability across latest browser versions since October 2024, while warning that older devices or browsers may not support it. Detect it with "requestVideoFrameCallback" in video and fall back to a tested seeked-based flow where needed. MDN also cautions that the callback is not guaranteed to remain strictly synchronized with the video frame rate, so validate the actual seek-and-capture behavior on the browser and media formats you support. See MDN’s requestVideoFrameCallback reference.

Validate timestamps and handle unusual timelines

A timestamp is a number of seconds, not a frame number. A requested time can be unavailable or land at a nearby supported point rather than an exact frame. This matters especially for compressed video, variable frame rates, and media with sparse keyframes: the browser’s seek behavior depends on the media timeline and available data.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
  • Finite, on-demand video: reject negative values and values beyond a finite video.duration. Check the seekable ranges too; a known duration does not prove that every portion has been downloaded or is seekable.
  • Live streams: duration can be unknown or effectively unbounded, and the seekable window can move. Check the current video.seekable ranges before each capture. A segment that has expired may no longer be available.
  • Non-zero timeline start: do not assume every media timeline begins at zero. Base validation on the actual seekable ranges rather than a hard-coded minimum.
  • Duplicate or nearby times: serial capture still works, but duplicates may produce the same image. If avoiding redundant output matters, normalize and deduplicate the requested times before processing.
  • Large batches: keep the loop sequential and limit the number of frames. Encoding many full-resolution images can consume substantial memory; this is an engineering concern, not a fixed browser limit.

When the requested timestamp falls outside the ranges reported by seekable, report that clearly or skip it according to the product’s needs. Do not silently label a nearby frame as the exact requested frame.

Export images and build a gallery

canvas.toBlob() is usually the better choice for downloadable image files. It returns binary image data that can be held or downloaded without creating a large encoded string. To show or download it, create an object URL with URL.createObjectURL(blob). Revoke URLs with URL.revokeObjectURL(url) when the gallery is removed or the images are no longer needed.

For a gallery, store each Blob alongside the requested timestamp. Keep the caption explicit about the requested time; if the application needs to distinguish it from the actual resulting video.currentTime, record both. toDataURL() can be convenient for a small preview, but it creates an encoded string in memory and is subject to the same canvas security restrictions as toBlob().

PNG is a straightforward lossless choice. Canvas encoders also support other formats depending on the browser; if you request a format the browser does not support, behavior may fall back to PNG. Check the resulting Blob’s type if the exact output format matters.

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

Cross-origin video and canvas security

A video from another origin may play in the page yet still be blocked from canvas export. Drawing cross-origin pixels without the server’s CORS approval taints the canvas. A tainted canvas blocks readback operations such as getImageData(), toBlob(), and toDataURL(), which can fail with a SecurityError.

  1. Set the video’s crossorigin attribute (or crossOrigin property) before assigning or loading its source. Use anonymous for ordinary CORS access; credentialed requests require an appropriately configured server.
  2. Ensure the media host returns CORS headers permitting the page’s origin.
  3. Reload the media after changing the CORS setting, then draw and export again.

For example, use <video crossorigin="anonymous" src="https://media.example/video.mp4">, provided that host is configured to allow your page’s origin. Adding the attribute alone cannot grant permission. Client-side JavaScript cannot override the remote server’s CORS policy; only use a same-origin proxy or host configuration when you control and are entitled to serve the media. See MDN’s guide to cross-origin images in canvas and MDN’s crossOrigin reference.

Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Troubleshooting common failures

Symptom Likely cause What to check or change
Canvas export throws SecurityError The video was drawn from another origin without successful CORS approval, tainting the canvas. Set crossorigin="anonymous" before loading and configure the media server’s CORS response. A JavaScript change alone cannot fix missing server permission.
Black, blank, or stale image The frame was drawn before the seek completed or before current-position data was ready. Wait for seeked; check video.seeking and video.readyState; test a frame-aware callback where supported.
Capture promise never resolves The target cannot be sought, a media error occurred, or the listener missed its event. Install the listener before setting currentTime, handle the media error event, add a timeout, and validate the target against current seekable ranges.
“Video dimensions are not available” Metadata has not loaded, or the media has no usable video dimensions. Wait for loadedmetadata, confirm video.videoWidth and video.videoHeight are nonzero, and check the source actually contains video.
Requested time is rejected or produces a nearby frame The time is outside the duration or seekable range, or the encoding/browser does not provide exact arbitrary-frame seeking. Check the current range and actual video.currentTime after seeking. Explain approximation rather than claiming frame-level precision.
Canvas encoding returns null The browser could not encode the canvas output. Confirm the canvas has nonzero dimensions and try a supported type such as image/png; handle the null result as an error.
It works on desktop but not some mobile devices Browser support may differ, or data saver can affect media readiness events. Feature-detect requestVideoFrameCallback(), avoid relying only on loadeddata, and test the target browser/device combination.

Performance, reliability, and cost considerations

This method performs capture in the visitor’s browser, so the page must load and decode the media and the browser must be able to seek to each requested point. Total time varies with source delivery, decoding, seek distance, and image encoding; there is no single reliable duration for all videos. Sequential processing is slower than issuing overlapping seeks, but it keeps each result associated with the intended timestamp and avoids races on one video element.

  • Capture only the frames the feature needs, especially at intrinsic video resolution.
  • Use Blob output and release object URLs and references when they are no longer needed.
  • Add per-seek timeouts and clear error reporting for media failures or unavailable ranges.
  • Test representative formats, browsers, devices, and CORS configurations; seek precision and readiness vary with the media.
  • For very large or unattended batch jobs, consider whether client-side capture is suitable for the user’s device, network, and memory limits.

Or skip the browser setup

If you need a screenshot of a web page rather than frames extracted from a video, ScreenshotNeo provides a website screenshot API and MCP server for developers. A single GET request returns a PNG, JPEG, WebP, or PDF capture. It is not a substitute for timestamped video-frame extraction: use the canvas method above when you need frames from an HTML5 video.

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

Example cURL request (see the ScreenshotNeo API documentation for request options):

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

ScreenshotNeo accepts cookie or consent banners before capture and removes more than 60 known consent platforms, newsletter popups, and chat widgets; those steps can be turned off. Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed, and responses identify the page verdict and billing status in headers. Its 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 shots per month without a card; paid plans start at $5 for 3,000 shots.

Sign up for 1,000 free screenshots a month, with no card required.

Frequently Asked Questions

Does this method capture every frame between two timestamps?

No. It captures only the times in the timestamp list. To sample a range, generate the desired time values and pass them to the same serial capture loop.

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

Can JavaScript guarantee an exact frame at an arbitrary time?

No. The browser seeks on the media timeline, and codec, browser, and available media data can affect the resulting position and frame.

Can I save the captured frames as JPEG or WebP instead of PNG?

Often, yes. Request the format in canvas.toBlob() and check the returned Blob type because supported encodings vary by browser.

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.