DEV Community

monkeymore studio
monkeymore studio

Posted on

Grab Perfect Frames from Any Video Without Uploading

Ever needed a still image from a video? Maybe a funny reaction face, a product shot from a commercial, or a keyframe from a tutorial. Most people either take a blurry screenshot of their media player or resort to importing the entire video into editing software just to export one frame.

We built something faster. Upload a video, scrub to the moment you want, hit capture, and get five high-quality frames around that timestamp. Pick the best one, download it. No upload to a server, no bloated software, no guesswork about whether you paused at exactly the right moment.

Why Extract Frames in the Browser?

Taking screenshots from video should not require a full editing suite:

  • No upload: Your video stays on your machine. We never see it.
  • No quality loss: We extract frames at the video's native resolution, not your screen resolution.
  • Multiple options: Instead of one screenshot, we give you five frames around your target time. Pick the best one.
  • Format choice: PNG for editing, JPEG for sharing, WebP for the web.
  • Instant: No rendering, no queue, no waiting.

How It Works

Here is the full flow from upload to downloaded frame:

The Data Model

We track the video, the current playback position, and the extracted keyframes:

interface Keyframe {
  id: string;
  time: number;
  imageUrl: string;
}

interface VideoFile {
  id: string;
  file: File;
  previewUrl: string;
  duration: number;
  videoWidth: number;
  videoHeight: number;
}
Enter fullscreen mode Exit fullscreen mode

Each keyframe stores its timestamp and a blob URL pointing to the captured image.

Two Ways to Extract Frames

We use a dual approach: Canvas API when possible, FFmpeg as a fallback.

Primary Method: Canvas API

If the browser can render the video, we draw frames directly to a canvas:

const captureWithCanvas = async (video: HTMLVideoElement) => {
  const canvas = document.createElement('canvas');
  const ctx = canvas.getContext('2d');

  canvas.width = video.videoWidth || selectedFile!.videoWidth;
  canvas.height = video.videoHeight || selectedFile!.videoHeight;

  const offsets = [-1.5, -0.75, 0, 0.75, 1.5];
  const generatedFrames: Keyframe[] = [];
  const originalTime = video.currentTime;

  for (let i = 0; i < offsets.length; i++) {
    const offset = offsets[i];
    const frameTime = Math.max(0, Math.min(selectedFile!.duration, currentTime + offset));

    video.currentTime = frameTime;

    await new Promise<void>((resolve) => {
      const onSeeked = () => {
        video.removeEventListener('seeked', onSeeked);
        resolve();
      };
      video.addEventListener('seeked', onSeeked);
      setTimeout(() => {
        video.removeEventListener('seeked', onSeeked);
        resolve();
      }, 100);
    });

    ctx.drawImage(video, 0, 0, canvas.width, canvas.height);

    const mimeType = imageFormat === 'jpeg' ? 'image/jpeg' : 
                    imageFormat === 'png' ? 'image/png' : 'image/webp';
    const quality = imageFormat === 'png' ? undefined : 0.95;

    const supportsFormat = canvas.toDataURL(mimeType).indexOf(mimeType) > -1;
    const actualMimeType = supportsFormat ? mimeType : 'image/jpeg';

    const blob = await new Promise<Blob | null>((resolve) => {
      canvas.toBlob((b) => resolve(b), actualMimeType, quality);
    });

    if (blob) {
      const imageUrl = URL.createObjectURL(blob);
      generatedFrames.push({ id: `frame-${i}`, time: frameTime, imageUrl });
    }
  }

  video.currentTime = originalTime;
  generatedFrames.sort((a, b) => a.time - b.time);
  setKeyframes(generatedFrames);
};
Enter fullscreen mode Exit fullscreen mode

A few things happening here:

  • We capture 5 frames around the current playback position: 1.5s before, 0.75s before, exactly at the timestamp, 0.75s after, and 1.5s after. This gives you options in case the exact frame you wanted was slightly off.
  • We wait for the seeked event after setting currentTime to ensure the video has actually loaded the frame before we draw it. A 100ms timeout acts as a fallback in case the event does not fire.
  • We check if the browser actually supports the requested image format. Some older browsers do not support WebP output from canvas, so we fall back to JPEG.
  • We restore the original playback position after capturing so the user is not disoriented.

Fallback Method: FFmpeg

Some video formats cannot be decoded by the browser's native player. In those cases, we fall back to FFmpeg:

const captureWithFFmpeg = async () => {
  const ffmpeg = ffmpegRef.current;
  const inputName = "input.mp4";

  const fileArrayBuffer = await selectedFile.file.arrayBuffer();
  await ffmpeg.writeFile(inputName, new Uint8Array(fileArrayBuffer));

  const offsets = [-1.5, -0.75, 0, 0.75, 1.5];
  const generatedFrames: Keyframe[] = [];

  for (let i = 0; i < offsets.length; i++) {
    const offset = offsets[i];
    const frameTime = Math.max(0, Math.min(selectedFile.duration, currentTime + offset));
    const outputName = `frame_${i}.jpg`;

    await ffmpeg.exec([
      "-ss", frameTime.toString(),
      "-i", inputName,
      "-vframes", "1",
      "-q:v", "2",
      "-pix_fmt", "rgb24",
      "-y",
      outputName
    ]);

    const data = await ffmpeg.readFile(outputName);

    if (data && data instanceof Uint8Array && data.length > 0) {
      let finalBlob: Blob;

      if (imageFormat === 'jpeg') {
        finalBlob = new Blob([data], { type: "image/jpeg" });
      } else {
        const img = new Image();
        const originalUrl = URL.createObjectURL(new Blob([data], { type: "image/jpeg" }));

        await new Promise<void>((resolve) => {
          img.onload = () => resolve();
          img.src = originalUrl;
        });

        const canvas = document.createElement('canvas');
        canvas.width = img.width;
        canvas.height = img.height;
        const ctx = canvas.getContext('2d');
        ctx?.drawImage(img, 0, 0);

        const mimeType = imageFormat === 'png' ? 'image/png' : 'image/webp';
        finalBlob = await new Promise<Blob>((resolve) => {
          canvas.toBlob((b) => resolve(b || new Blob()), mimeType, 0.95);
        });

        URL.revokeObjectURL(originalUrl);
      }

      const imageUrl = URL.createObjectURL(finalBlob);
      generatedFrames.push({ id: `frame-${i}`, time: frameTime, imageUrl });
    }

    await ffmpeg.deleteFile(outputName);
  }

  await ffmpeg.deleteFile(inputName);
  generatedFrames.sort((a, b) => a.time - b.time);
  setKeyframes(generatedFrames);
};
Enter fullscreen mode Exit fullscreen mode

FFmpeg always outputs JPEG first. If the user requested PNG or WebP, we load the JPEG into an Image element, draw it to a canvas, and export in the desired format.

The FFmpeg command breaks down as:

  • -ss frameTime: Seek to the target timestamp
  • -vframes 1: Extract exactly one frame
  • -q:v 2: High quality (lower numbers are better for JPEG)
  • -pix_fmt rgb24: Full color output

Why Two Methods?

The Canvas API is faster and lighter — no need to load FFmpeg into memory. But some codecs (certain H.264 profiles, older formats) cannot be decoded by browsers. FFmpeg supports virtually everything, so it acts as a safety net.

We decide which method to use by checking the video element's state:

const canUseCanvas = video.videoWidth > 0 && video.videoHeight > 0 && video.readyState >= 2;

if (canUseCanvas) {
  await captureWithCanvas(video);
} else {
  await captureWithFFmpeg();
}
Enter fullscreen mode Exit fullscreen mode

Pick Your Format

We offer three output formats:

  • PNG: Lossless, perfect for editing. Large file size.
  • JPEG: Compressed, great for sharing. Small file size.
  • WebP: Modern format with excellent compression. Not supported by all older software.

We default to PNG because screenshots are usually going into some kind of workflow where quality matters. But you can switch before capturing.

Navigating the Video

We provide arrow buttons to nudge the playback position by 1 second in either direction:

const seekVideo = (direction: "backward" | "forward") => {
  if (!videoRef.current) return;
  const step = direction === "backward" ? -1 : 1;
  videoRef.current.currentTime = Math.max(0, Math.min(
    selectedFile?.duration || 0,
    videoRef.current.currentTime + step
  ));
};
Enter fullscreen mode Exit fullscreen mode

The current time updates in real time via the video's timeupdate event, so you always know exactly where you are.

Downloading Frames

You can download individual frames or grab all five at once:

const handleDownload = (frame: Keyframe) => {
  const link = document.createElement("a");
  link.href = frame.imageUrl;
  const extension = imageFormat === 'jpeg' ? 'jpg' : imageFormat;
  link.download = `screenshot_${formatTime(frame.time).replace(/[:.]/g, "-")}.${extension}`;
  document.body.appendChild(link);
  link.click();
  document.body.removeChild(link);
};

const handleDownloadAll = () => {
  keyframes.forEach((frame, index) => {
    setTimeout(() => {
      handleDownload(frame);
    }, index * 200);
  });
};
Enter fullscreen mode Exit fullscreen mode

The 200ms stagger between downloads prevents browsers from blocking multiple simultaneous downloads as potential spam.

Filenames include the timestamp so you know exactly when each frame was captured:

screenshot_01-23-45.png
screenshot_01-24-15.png
screenshot_01-24-60.png
Enter fullscreen mode Exit fullscreen mode

Video Error Handling

We handle playback errors gracefully:

onError={(e) => {
  const video = e.currentTarget;
  const errorCode = video.error?.code;
  let errorText = "Failed to load video";
  if (errorCode === 1) errorText = "Video loading aborted";
  else if (errorCode === 2) errorText = "Network error while loading video";
  else if (errorCode === 3) errorText = "Video decoding error - format may not be supported";
  else if (errorCode === 4) errorText = "Video format not supported by browser";
  setVideoError(errorText);
}}
Enter fullscreen mode Exit fullscreen mode

If the browser cannot play the video, we show a helpful message and the FFmpeg fallback kicks in during capture.

Loading FFmpeg on Demand

// utils/ffmpegLoader.ts
import { fetchFile, toBlobURL } from "@ffmpeg/util";

let ffmpeg: any = null;
let fetchFileFn: any = null;

export async function loadFFmpeg() {
  if (ffmpeg) return { ffmpeg, fetchFile: fetchFileFn };

  const { FFmpeg } = await import("@ffmpeg/ffmpeg");

  ffmpeg = new FFmpeg();
  fetchFileFn = fetchFile;

  const baseURL = "https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.6/dist/umd";

  await ffmpeg.load({
    coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, "text/javascript"),
    wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, "application/wasm"),
  }, {
    corePath: await toBlobURL(`${baseURL}/ffmpeg-core.js`, "text/javascript"),
  });

  return { ffmpeg, fetchFile };
}
Enter fullscreen mode Exit fullscreen mode

Cleanup

Frames are stored as blob URLs, which can accumulate memory:

const reset = useCallback(() => {
  if (selectedFile) {
    URL.revokeObjectURL(selectedFile.previewUrl);
  }
  keyframes.forEach(frame => {
    URL.revokeObjectURL(frame.imageUrl);
  });
  setSelectedFile(null);
  setCurrentTime(0);
  setKeyframes([]);
  setSelectedFrame(null);
  setError(null);
}, [selectedFile, keyframes]);
Enter fullscreen mode Exit fullscreen mode

What We Learned

Building this tool revealed a few interesting challenges:

  • Canvas extraction is not instant: After setting video.currentTime, you have to wait for the seeked event before drawing. Without this, you capture the previous frame. The 100ms timeout fallback is essential because some browsers fire the event inconsistently.
  • Browser WebP support varies: We check canvas.toDataURL('image/webp') to see if the browser actually supports WebP output before trying to use it. Safari did not support WebP canvas export until relatively recently.
  • FFmpeg JPEG is the lowest common denominator: Since FFmpeg can always output JPEG, we use that as the intermediate format and convert to PNG/WebP via canvas. This avoids needing to compile extra encoders into FFmpeg.wasm.
  • Five frames is the sweet spot: We experimented with 3, 5, and 9 frames. Three felt like too few options. Nine was overwhelming. Five frames — spanning 3 seconds total — gives enough choice without clutter.
  • The crossOrigin="anonymous" attribute matters: Without it, some browsers throw security errors when trying to draw a locally-created video to a canvas. Even though the video comes from a local blob URL, the attribute signals that the operation is safe.

Give It a Try

Need a still image from a video? You can extract frames right now. No upload, no editing software, no quality loss.

šŸ‘‰ Video Screenshot Tool

Upload your video, scrub to the moment you want, pick your format, and capture. Your video never leaves your browser.

Top comments (0)