ScreenshotNeo

BlogScreenshots on your device

How to Capture Screenshots from a Video Source in Electron

Capture a decoded Electron video frame by drawing it to canvas, then export PNG, JPEG, WebP, or a file with reliable cleanup and timing.

By the ScreenshotNeo team1 October 20268 min read

How to Capture Screenshots from a Video Source in Electron

Direct answer: In Electron, obtain a desktop or window MediaStream, attach it to an HTML <video>, wait for a decoded frame, draw that frame into a canvas, and export the canvas as PNG, JPEG, WebP, or a Blob. Electron supplies the stream; the renderer performs the screenshot operation.

The complete flow is: choose a source with desktopCapturer.getSources() or let getDisplayMedia() open the system picker; assign the stream to video.srcObject; call video.play(); wait for usable intrinsic dimensions; call ctx.drawImage(video, 0, 0, width, height); encode the canvas; then stop every video track.

Electron documents the source APIs in its desktopCapturer documentation and display-media request handler documentation. The canvas operation follows MDN guidance for drawImage() and canvas encoding.

1. Complete Electron example

This example uses a preload bridge, context isolation, the system display picker, a video preview, one-frame capture, PNG download, and cleanup.

Electron passes a MediaStream to video; canvas copies the decoded frame for export.
Electron passes a MediaStream to video; canvas copies the decoded frame for export.

Project files

electron-video-shot/
package.json
main.js
preload.js
index.html
renderer.js

package.json

{
'name': 'electron-video-shot',
'version': '1.0.0',
'main': 'main.js',
'scripts': { 'start': 'electron .' },
'devDependencies': { 'electron': 'latest' }
}

main.js

const { app, BrowserWindow, desktopCapturer, session } = require('electron');
const path = require('node:path');

function createWindow() {
const win = new BrowserWindow({
width: 1100,
height: 800,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false
}
});
win.loadFile('index.html');
}

app.whenReady().then(() => {
// Choose a source automatically. Replace this with a user-facing picker
// when the application must let users choose a specific window or screen.
session.defaultSession.setDisplayMediaRequestHandler(async (request, callback) => {
const sources = await desktopCapturer.getSources({
types: ['screen', 'window'],
thumbnailSize: { width: 320, height: 200 }
});
const source = sources[0];
callback(source ? { video: source } : {});
});

createWindow();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
});

app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});

The request handler lets the main process preselect a source returned by desktopCapturer.getSources(). For an interactive operating-system picker, remove the handler and call navigator.mediaDevices.getDisplayMedia() in the renderer. Electron notes that getDisplayMedia() does not accept a deviceId for silently selecting a display source.

preload.js

const { contextBridge } = require('electron');

contextBridge.exposeInMainWorld('captureAPI', {
appName: 'Electron video capture'
});

index.html

<!doctype html>
<html lang='en'>
<head>
<meta charset='utf-8'>
<title>Electron video screenshot</title>
<style>
video { max-width: 800px; background: #111; }
canvas { display: none; }
img { max-width: 800px; display: block; margin-top: 1rem; }
</style>
</head>
<body>
<button id='start'>Start capture</button>
<button id='shot' disabled>Capture PNG</button>
<button id='stop' disabled>Stop</button>
<a id='download' hidden>Download PNG</a>
<video id='preview' autoplay muted playsinline></video>
<canvas id='frame'></canvas>
<img id='result' alt='Captured frame preview'>
<script src='renderer.js'></script>
</body>
</html>

renderer.js

const video = document.querySelector('#preview');
const canvas = document.querySelector('#frame');
const result = document.querySelector('#result');
const download = document.querySelector('#download');
const startButton = document.querySelector('#start');
const shotButton = document.querySelector('#shot');
const stopButton = document.querySelector('#stop');
const ctx = canvas.getContext('2d', { alpha: false });
let stream = null;

async function waitForVideoData() {
if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) return;
await new Promise((resolve, reject) => {
const onData = () => { cleanup(); resolve(); };
const onError = () => { cleanup(); reject(new Error('Video failed to decode')); };
const cleanup = () => {
video.removeEventListener('loadeddata', onData);
video.removeEventListener('error', onError);
};
video.addEventListener('loadeddata', onData, { once: true });
video.addEventListener('error', onError, { once: true });
});
}

async function startCapture() {
stream = await navigator.mediaDevices.getDisplayMedia({
video: { frameRate: { ideal: 30, max: 60 } },
audio: false
});
video.srcObject = stream;
await video.play();
await waitForVideoData();
if (!video.videoWidth || !video.videoHeight) {
throw new Error('No decoded video frame is available yet');
}
shotButton.disabled = false;
stopButton.disabled = false;
stream.getVideoTracks()[0].addEventListener('ended', stopCapture, { once: true });
}

function capturePng() {
if (!video.videoWidth || !video.videoHeight) {
throw new Error('No decoded video frame is available yet');
}
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
const dataUrl = canvas.toDataURL('image/png');
result.src = dataUrl;
download.href = dataUrl;
download.download = 'electron-frame.png';
download.hidden = false;
return dataUrl;
}

function captureBlob(type = 'image/png', quality) {
return new Promise((resolve, reject) => {
canvas.toBlob(blob => blob ? resolve(blob) : reject(new Error('Canvas encoding failed')), type, quality);
});
}

function stopCapture() {
if (stream) {
for (const track of stream.getVideoTracks()) track.stop();
}
stream = null;
video.srcObject = null;
shotButton.disabled = true;
stopButton.disabled = true;
}

startButton.addEventListener('click', async () => {
try { await startCapture(); }
catch (error) { console.error(error); alert(error.message); stopCapture(); }
});
shotButton.addEventListener('click', () => {
try { capturePng(); }
catch (error) { console.error(error); alert(error.message); }
});
stopButton.addEventListener('click', stopCapture);

Run the project with npm install, then npm start. The important ordering is srcObject, play(), wait for data, set canvas dimensions from videoWidth and videoHeight, and only then draw.

2. Capture the exact next video frame

A button click can occur between decoded frames. For frame-timed capture, use requestVideoFrameCallback(). MDN describes it as a callback that runs when a new video frame is sent to the compositor.

Use requestVideoFrameCallback when the captured frame must align with a newly presented video frame.
Use requestVideoFrameCallback when the captured frame must align with a newly presented video frame.
function captureNextFramePng() {
return new Promise((resolve, reject) => {
const draw = () => {
try {
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
resolve(canvas.toDataURL('image/png'));
} catch (error) {
reject(error);
}
};

if (typeof video.requestVideoFrameCallback === 'function') {
video.requestVideoFrameCallback(draw);
} else {
requestAnimationFrame(draw);
}
});
}

Use the fallback for older embedded Chromium versions. In a repeated capture loop, schedule the next callback only after processing the current frame, and stop scheduling when the track ends.

3. Export PNG, JPEG, WebP, or a file

const pngDataUrl = canvas.toDataURL('image/png');
const jpegDataUrl = canvas.toDataURL('image/jpeg', 0.9);
const webpDataUrl = canvas.toDataURL('image/webp', 0.9);

PNG is lossless and preserves text. JPEG and WebP can be smaller when some loss is acceptable. A data URL keeps the complete image in memory, so use a blob for large frames or repeated captures.

async function canvasBlob(type = 'image/png', quality) {
return new Promise((resolve, reject) => {
canvas.toBlob(blob => {
if (blob) resolve(blob);
else reject(new Error('Canvas encoding returned null'));
}, type, quality);
});
}

async function downloadBlob() {
const blob = await canvasBlob('image/png');
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'frame.png';
a.click();
setTimeout(() => URL.revokeObjectURL(url), 0);
}

For a native save location, send blob bytes through the preload bridge to a main-process IPC handler using Electron’s save dialog and Node filesystem APIs. Keep filesystem access in the main process.

4. Source-selection options

Approach Use it when Trade-off
System picker with getDisplayMedia() The user should choose a screen or window interactively. Selection is controlled by the display-capture flow; a deviceId cannot silently select a display.
desktopCapturer.getSources() plus request handler The application needs a known screen or window. You must present a safe source choice and handle an empty list.

getSources() returns sources representing screens or individual windows. Request ['screen'], ['window'], or both according to your product behavior.

5. Permissions and platform behavior

  • macOS: Screen capture requires user consent on macOS 10.15 and later. Handle denial and direct the user to Screen Recording permissions in System Settings.
  • Linux PipeWire: Requesting both screen and window types can yield only one capture, with the selected source treated as a window capture. Test the deployed compositor.
  • All platforms: Users can revoke capture or close the source. Listen for the track’s ended event and run the same cleanup as the Stop button.

6. Troubleshooting

Symptom Cause Fix
videoWidth === 0 No decoded frame or metadata yet. Await loadeddata, call play(), and check dimensions before drawing.
Black or stale image The canvas was drawn before playback produced a frame. Use requestVideoFrameCallback() or wait for current data.
NotAllowedError User denial or missing OS permission. Handle the rejection, explain the permission, and let the user retry.
NotFoundError or an empty source list No eligible display or window source exists. Check requested source types and the platform session.
InvalidStateError The media request is not in a usable state. Start capture from a user gesture and avoid concurrent requests.
Canvas export fails Zero-size canvas, encoding failure, or memory pressure. Set dimensions from intrinsic video size, reduce scale, and use toBlob().
Only one Linux source appears PipeWire source semantics. Request one source type at a time and test on the target compositor.
Capture indicator remains active A video track was not stopped. Stop every track and set video.srcObject = null.

7. Performance, reliability, and cost

Performance

  • Canvas dimensions determine pixels copied and encoded. Use intrinsic dimensions for fidelity or downscale deliberately for thumbnails.
  • CSS resizing changes presentation, not the decoded source dimensions used by drawImage().
  • Repeated PNG encoding is CPU and memory intensive. Prefer WebP or JPEG when lossless output is unnecessary.
  • Reuse one canvas and revoke object URLs after downloads.

Reliability

  • Start from a user action, handle rejected promises, and treat ended as a normal shutdown.
  • Wait for playback and a usable frame. loadedmetadata gives dimensions; loadeddata or a frame callback indicates drawable data.
  • Record the source name and timestamp if the image must be audited later.
  • Do not assume identical screen and window behavior across operating systems.

Cost

Local Electron capture has no ScreenshotNeo API charge. It uses the user’s machine, permissions, CPU, memory, and storage. A hosted screenshot API introduces plan limits, transfer, retries, and cache considerations.

8. Or skip the browser setup

If the input is a public webpage URL rather than a local desktop stream, ScreenshotNeo returns a screenshot or PDF with one GET request. See the ScreenshotNeo API docs.

curl -G 'https://api.screenshotneo.com/v1/shot' -d access_key=YOUR_API_KEY --data-urlencode url=https://example.com -o shot.webp
import requests
r = requests.get('https://api.screenshotneo.com/v1/shot', params={'access_key': 'YOUR_API_KEY', 'url': 'https://example.com'}, timeout=90)
open('shot.webp', 'wb').write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://example.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

Before capture, cookie and consent banners are accepted and removed along with more than 60 known consent platforms, newsletter popups, and chat widgets; each cleanup step can be turned off. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and responses identify the result with X-Page-Verdict and X-Billed headers. ScreenshotNeo also provides an MCP server with take_screenshot, get_page_info, and capture_pdf for Claude, Cursor, and other MCP clients. The Free plan includes 1,000 screenshots per month without a card; paid plans start at $5 for 3,000 shots. Create a free ScreenshotNeo account.

9. FAQ

Can Electron save a screenshot directly from desktopCapturer?

No. It provides a source that becomes a MediaStream. Attach that stream to video and copy a decoded frame to canvas.

Should I use toDataURL() or toBlob()?

Use toDataURL() for a small preview or simple download. Use toBlob() for larger images, uploads, and repeated captures.

How do I capture an element inside a window?

Display capture gives you the selected screen or window. For a DOM element, render the page in a renderer and use a DOM screenshot technique, or capture the window and crop the canvas using known coordinates.

Why does the screenshot differ from the monitor?

Captured pixels reflect decoded dimensions, device scale, compositor state, and platform-specific behavior. Log video.videoWidth and video.videoHeight when diagnosing differences.

How do I stop capture safely?

Stop every video track, clear srcObject, revoke object URLs, and disable controls. Handle ended so closing the source performs the same cleanup.