Screenshot from the server instead of the browser

getDisplayMedia costs a picker, a permission, and a "Sharing this tab" bar
parked over the projector all night. None of it can be suppressed from the
page — the indicator is a security surface with no API, and the Chromium
switches that hide it were no help here.

The server runs on the machine the screen is plugged into, so it can take the
screenshot itself. server/screen.js drives grim for one region and /shot
returns the finished crop, which means grab() hands back a URL for an <img>
rather than a data URL off a canvas: same synchronous contract, so the
animations are untouched, and no tainting to work around either.

Coordinates line up because a fullscreen page's CSS pixels are the
compositor's logical pixels, which is the space grim's -g takes. The monitor
layout comes from hyprctl, so a projector is measured against itself rather
than the laptop panel, and -s pins the scale to that monitor: it keeps a
mixed-DPI desk deterministic, and it keeps grim native, where resampling costs
440ms against 35ms.

Needs a wlroots compositor and grim on PATH; without them the route reports
501 and the player falls back to getDisplayMedia, probed at load because that
call cannot be reached through an await without losing transient activation.
Also skipped when the page is not on localhost, where the server would be
photographing its own screen rather than the viewer's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H9v2Xoa6eoeGYpK6gp95k9
This commit is contained in:
simon
2026-08-10 22:49:36 +02:00
co-authored by Claude Opus 5
parent cc6ea58c7c
commit 0683acd69e
5 changed files with 351 additions and 68 deletions
+8 -6
View File
@@ -49,14 +49,16 @@ async function start() {
if (started) return;
started = true;
// Before any await: getDisplayMedia needs the click's transient activation,
// which is gone the moment this function yields. It goes first for the same
// reason — it is the call that must not lose the activation to another API.
// Before any await: the getDisplayMedia fallback needs the click's transient
// activation, which is gone the moment this function yields. It goes first for
// the same reason — it is the call that must not lose the activation to
// another API. The server-screenshot path needs no gesture, but cannot be
// chosen without one, so this ordering has to hold either way.
const capturing = requestCapture({ onIssue: showNotice });
// Screenshots only line up while the viewport covers the whole captured
// surface. Firefox cannot share a bare tab, so the operator shares the screen
// and this makes the page match it. Harmless on the tab-capture path.
// Screenshots only line up while the viewport covers the whole screen, since
// that is what makes a CSS pixel a screen pixel. Chrome's tab capture is the
// one exception, and fullscreen does it no harm.
document.documentElement.requestFullscreen?.().catch(() => {
showNotice('Could not go fullscreen — press F11 so screenshots line up.');
});