DEV Community

Cover image for Grabbing full-resolution video frames in the browser, without uploading anything
Pullo
Pullo

Posted on

Grabbing full-resolution video frames in the browser, without uploading anything

Pause a video, take a screenshot, crop it. That's how most people get a still image out of a clip, and the result is usually worse than it should be: player controls in the corner, the picture scaled to your screen instead of the video's real size, and an extra round of compression.

I wanted something better for the free tools on my site, so I built a frame grabber that reads the video file directly and saves the exact frame at the video's own resolution. A 1080p clip gives you a 1920 × 1080 PNG. Nothing is uploaded, because the whole thing runs in the browser.

It turned out to be about 40 lines of plain JavaScript. Here's how it works, and the small traps I ran into.

Live demo: downloaders.co/video-frame-grabber

1. Load the file without uploading it

The user picks a file with a normal <input type="file" accept="video/*"> (or drops it). Instead of sending it anywhere, I turn it into an object URL and give it to a <video> element:

const video = document.querySelector('video');

input.addEventListener('change', () => {
  const file = input.files[0];
  video.src = URL.createObjectURL(file);
});
Enter fullscreen mode Exit fullscreen mode
<video controls playsinline preload="metadata"></video>
Enter fullscreen mode Exit fullscreen mode

Two attributes matter here:

  • preload="metadata" loads the dimensions and duration without decoding the whole file up front.
  • playsinline stops iOS from opening its fullscreen player when the video starts.

The user can now scrub through the video with the native controls and stop exactly where they want.

2. Draw the current frame onto a canvas

This is the core of the tool. Make a canvas the same size as the video (not the size it's displayed at) and draw the video onto it:

function grab(format) {
  const canvas = document.createElement('canvas');
  canvas.width = video.videoWidth;
  canvas.height = video.videoHeight;
  canvas.getContext('2d').drawImage(video, 0, 0);

  return new Promise(resolve => canvas.toBlob(resolve, format, 0.95));
}
Enter fullscreen mode Exit fullscreen mode

videoWidth and videoHeight are the intrinsic size of the video stream. Using them instead of the element's CSS size is what gives you a full-resolution image rather than whatever fits on screen.

canvas.toBlob() takes a MIME type, so the same function produces PNG, JPEG or WebP:

const EXT = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp' };
Enter fullscreen mode Exit fullscreen mode

The 0.95 quality argument only applies to JPEG and WebP. PNG is lossless and ignores it.

To let the user save the result, I create another object URL for the blob and put it on a download link:

const blob = await grab('image/png');
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `frame-${video.currentTime.toFixed(1)}s.png`;
Enter fullscreen mode Exit fullscreen mode

Putting the timestamp in the filename sounds minor, but it's the first thing people ask for once they've saved ten frames from the same clip.

3. Capture a frame every N seconds

The second mode saves a frame at a fixed interval, which is handy for storyboards or for finding the best thumbnail. This is where the first real gotcha shows up.

Setting currentTime doesn't move the video immediately. It starts a seek, and the frame is only ready when the seeked event fires. If you draw right after setting it, you get the old frame. So I wrapped seeking in a promise:

function seek(t) {
  return new Promise(resolve => {
    video.addEventListener('seeked', function handler() {
      video.removeEventListener('seeked', handler);
      resolve();
    });
    video.currentTime = t;
  });
}
Enter fullscreen mode Exit fullscreen mode

The second gotcha: there's only one video element, so the seeks have to happen one after another, not in parallel. A Promise.all over twenty timestamps would fire twenty seeks at once and you'd get twenty copies of whichever frame won. Chaining them with reduce keeps them in order:

async function captureEvery(seconds) {
  video.pause();

  const times = [];
  for (let t = 0; t < video.duration && times.length < 60; t += seconds) {
    times.push(t);
  }

  await times.reduce(
    (prev, t) => prev.then(() => seek(t)).then(() => grab(format)).then(addThumbnail),
    Promise.resolve()
  );
}
Enter fullscreen mode Exit fullscreen mode

I cap it at 60 frames. A full-HD PNG can easily be a few megabytes, and every one stays in memory as a blob until the page is closed. Sixty is plenty for a storyboard and keeps phones from struggling.

4. Check that the video is actually ready

If someone clicks "capture" before the metadata has loaded, videoWidth is still 0. You get a zero-sized canvas and an empty file. One line prevents that:

if (!video.videoWidth) {
  showMessage('Wait until the video is loaded.');
  return;
}
Enter fullscreen mode Exit fullscreen mode

Why this works on local files and not on any URL

If you try the same trick on a video from another domain, drawImage still works, but the canvas becomes "tainted" and toBlob() throws a security error, unless that server sends the right CORS headers. Most don't.

Local files loaded through URL.createObjectURL() don't have this problem, because the browser treats them as same-origin. That's a nice side effect of the "no upload" design: the privacy-friendly approach is also the one that just works.

Format support is up to the browser

Since decoding is done by the browser, the tool supports whatever the browser can play. MP4 with H.264 works everywhere. WebM works in Chrome and Firefox but not in older Safari. MOV files depend on the codec inside them: an H.264 MOV is fine, while HEVC only plays where the system supports it.

When a file won't load, the honest answer is "your browser can't decode this one," and converting it to MP4 first is usually the fix.

What I'd improve next

  • Release memory. Each captured frame gets an object URL that is never revoked. Calling URL.revokeObjectURL() when a thumbnail is removed would make long sessions lighter.
  • Frame-exact stepping. currentTime seeks to a time, not a specific frame. For frame-by-frame stepping, requestVideoFrameCallback() gives you the exact presentation time of each frame, in browsers that support it.
  • Download all as ZIP for the interval mode, instead of one file at a time.

Try it

The tool is live at downloaders.co/video-frame-grabber. It's one of the small browser-only utilities on Pullo, next to a video to MP3 converter and an audio cutter that work the same way.

If you've built something with <video> and <canvas>, I'd like to hear which browser quirks bit you. Safari usually has at least one.

Top comments (0)