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;
}
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);
};
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
seekedevent after settingcurrentTimeto 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);
};
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();
}
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
));
};
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);
});
};
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
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);
}}
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 };
}
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]);
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 theseekedevent 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.
Upload your video, scrub to the moment you want, pick your format, and capture. Your video never leaves your browser.

Top comments (0)