Hao

You hit PrintScreen on a video, paste, and get a black rectangle where the picture was. The window
frame is there. The controls are there. The video is a hole.

The video was never in the pixels you copied.

What actually happened

A hardware-accelerated video does not get painted into the same surface as the rest of the window.
The decoder hands frames to the GPU, which composites them into an overlay plane at the very last
step, after the window contents have been assembled. A screen capture that reads the window surface
reads what is underneath that plane, which is the punch-out colour: black.

This is why the same PrintScreen works fine on some machines and fails on others, why it starts
failing after a GPU driver update, and why "disable hardware acceleration" is the advice that keeps
getting handed out. Turning off acceleration does fix it, by making the video render into the normal
paint path, along with the battery cost.

The better move is to stop capturing the window and ask the <video> element for the frame instead.

Grab the frame from the element

drawImage accepts a <video> and reads the frame it is currently showing:

const v = document.querySelector("video");
const c = document.createElement("canvas");
c.width = v.videoWidth;          // NOT v.clientWidth
c.height = v.videoHeight;
c.getContext("2d").drawImage(v, 0, 0);
c.toBlob(b => open(URL.createObjectURL(b)), "image/png");

Enter fullscreen mode Exit fullscreen mode

Paste that into the console on a YouTube tab and you get the exact frame, no black.

The one line worth staring at is v.videoWidth. That is the resolution of the decoded stream, which
is what you want. clientWidth is the size of the CSS box, so using it silently downsamples a 4K
frame to whatever your player happens to be sized at in that window. This is the difference between a
usable still and a soft 640-pixel-wide one, and it is easy to get wrong because both produce an image.

Set the player to 2160p first if you want 2160p out. videoWidth reports the stream YouTube is
currently feeding you, not the best one available.

Straight to the clipboard

Skipping the file is often what you want, since the frame is usually headed for a slide or a note:

const png = await new Promise(r => c.toBlob(r, "image/png"));
await navigator.clipboard.write([new ClipboardItem({ "image/png": png })]);

Enter fullscreen mode Exit fullscreen mode

Two constraints that will bite you. The clipboard only takes PNG here, so if you produced a JPEG
blob you have to re-encode before writing. And clipboard.write needs a user gesture and a focused
document, so it works from a click handler and fails from a setTimeout.

When it still comes out black

drawImage on a DRM-protected video throws instead of returning pixels. Widevine-protected content —
a rented film, most paid streaming — is out of reach by design, and no extension or console snippet
changes that. YouTube's ordinary videos are not protected, so they are fine.

Wrap it, because an unhandled throw here looks identical to a broken tool:

try { ctx.drawImage(v, 0, 0); } catch { /* protected stream */ }

Enter fullscreen mode Exit fullscreen mode

Naming the file

If you are saving rather than copying, put the timestamp in the name while you still have it:

const t = Math.floor(v.currentTime);
const stamp = `${String(t/3600|0).padStart(2,"0")}-${String(t/60%60|0).padStart(2,"0")}-${String(t%60).padStart(2,"0")}`;

Enter fullscreen mode Exit fullscreen mode

Frames without a timestamp in the filename are unfindable a week later, which anyone who has taken
lecture notes this way has learned the hard way.

The extension

I packaged this as Screenshot for YouTube — Video Frame
Capture
. Disclosure:
mine, free, no account, no network requests.

It puts a camera button in the player controls, binds Alt+S, saves PNG or JPEG or copies to the
clipboard, and names files with the video title plus the timestamp above. There is a popup capture
button as a fallback for Shorts and embeds, where the standard control bar is absent. Getting the
button to survive YouTube's SPA navigation needed a MutationObserver plus the yt-navigate-finish
event, which is the least interesting and most fiddly part of the whole thing.

If you only need one frame today, the console snippet above is the entire mechanism. The extension
exists because the twentieth time you do it by hand, videoWidth is not the thing you want to be
remembering.