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
+36
View File
@@ -10,6 +10,7 @@ import { WebSocketServer } from 'ws';
import * as config from '../config.js';
import { PORT } from '../config.js';
import { parseVideoId, fetchTitle } from './youtube.js';
import * as screen from './screen.js';
import * as store from './state.js';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
@@ -41,6 +42,41 @@ app.get('/config.js', (_req, res) => {
);
});
// Screenshots of the player's own frame (see server/screen.js). The player
// points an <img> straight at this, so the response has to be the finished
// crop — there is no canvas step on the other end.
app.get('/shot', async (req, res) => {
const rect = {
x: Number(req.query.x),
y: Number(req.query.y),
width: Number(req.query.w),
height: Number(req.query.h),
screenWidth: Number(req.query.sw),
screenHeight: Number(req.query.sh),
};
if (Object.values(rect).some((value) => !Number.isFinite(value))) {
res.sendStatus(400);
return;
}
// 501, not 404: the player probes this route to decide whether to fall back
// to getDisplayMedia, and needs to tell "wrong machine" from "no route".
if (!(await screen.available())) {
res.sendStatus(501);
return;
}
const jpeg = await screen.capture(rect);
if (!jpeg) {
res.sendStatus(422);
return;
}
// Every request carries a fresh nonce, so a cached copy would only ever be a
// stale photograph of a moment that has passed.
res.type('image/jpeg').set('Cache-Control', 'no-store').send(jpeg);
});
const server = createServer(app);
const wss = new WebSocketServer({ server });
+152
View File
@@ -0,0 +1,152 @@
// Screen capture, done by the machine instead of by the browser.
//
// The decks are cross-origin YouTube iframes, so the page cannot read their
// pixels. The browser's own answer to that is getDisplayMedia, which costs a
// picker, a permission, and a "Sharing this tab" bar parked over the projector
// all night — and on Firefox cannot share a tab at all. Since the server runs
// on the same machine as the screen it is trying to photograph, it can just
// take the screenshot itself.
//
// grim is the wlroots screencopy client (Hyprland, Sway, river). Elsewhere this
// reports unavailable and the player falls back to getDisplayMedia.
import { spawn } from 'node:child_process';
// A region grab is ~40ms and ~25KB at this quality; the stills are shown at
// screen size for a few seconds, so there is nothing to gain from going higher.
const QUALITY = 80;
// grim is quick, but a hung compositor must not hold a request open forever.
const TIMEOUT_MS = 2000;
// hyprctl is cheap, but it is called once per screenshot and the monitor layout
// does not change during a party.
const LAYOUT_TTL_MS = 5000;
/** True once we know grim can actually run — probed lazily, then remembered. */
let grimAvailable = null;
let layout = null;
let layoutAt = 0;
function run(command, args, { input = null } = {}) {
return new Promise((resolve) => {
let child;
try {
child = spawn(command, args);
} catch {
resolve(null);
return;
}
const chunks = [];
let settled = false;
const timer = setTimeout(() => {
settled = true;
child.kill('SIGKILL');
resolve(null);
}, TIMEOUT_MS);
child.stdout.on('data', (chunk) => chunks.push(chunk));
// Without a drain grim can block on a full stderr pipe.
child.stderr.resume();
child.on('error', () => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve(null);
});
child.on('close', (code) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve(code === 0 ? Buffer.concat(chunks) : null);
});
if (input !== null) child.stdin.end(input);
});
}
/**
* The monitors, in the compositor's logical coordinate space — the same space
* grim's -g takes, and the same one CSS pixels live in while a page is
* fullscreen. Empty when hyprctl is missing, which the caller reads as "one
* screen, at the origin".
*/
async function monitors() {
const now = Date.now();
if (layout && now - layoutAt < LAYOUT_TTL_MS) return layout;
const out = await run('hyprctl', ['monitors', '-j']);
let parsed = [];
if (out) {
try {
parsed = JSON.parse(out.toString()).map((monitor) => ({
x: monitor.x,
y: monitor.y,
scale: monitor.scale,
// hyprctl reports the mode in device pixels; everything else here, grim
// included, works in logical ones.
width: Math.round(monitor.width / monitor.scale),
height: Math.round(monitor.height / monitor.scale),
}));
} catch {
parsed = [];
}
}
layout = parsed;
layoutAt = now;
return layout;
}
/** Whether this machine can be screenshotted at all. */
export async function available() {
if (grimAvailable === null) {
grimAvailable = (await run('grim', ['-h'])) !== null;
}
return grimAvailable;
}
/**
* Photograph one rectangle of the screen the player is on.
*
* `rect` is in CSS pixels, which equal logical screen pixels while the page is
* fullscreen — that equality is the whole reason the caller insists on
* fullscreen. `screenWidth`/`screenHeight` name which monitor to measure
* against, so a player on a projector is not cropped against the laptop panel.
*
* Resolves to a JPEG buffer, or null if grim is unusable or the rectangle
* falls outside the screen.
*/
export async function capture({ x, y, width, height, screenWidth, screenHeight }) {
if (!(await available())) return null;
const screens = await monitors();
const screen =
screens.find((m) => m.width === screenWidth && m.height === screenHeight) ??
screens[0] ??
{ x: 0, y: 0, scale: null, width: screenWidth, height: screenHeight };
// The frame can hang off an edge mid-bounce. Clip rather than refuse: grim
// fails outright on a region that leaves the output.
const left = Math.max(0, Math.min(x, screen.width));
const top = Math.max(0, Math.min(y, screen.height));
const right = Math.max(left, Math.min(x + width, screen.width));
const bottom = Math.max(top, Math.min(y + height, screen.height));
const w = Math.round(right - left);
const h = Math.round(bottom - top);
if (w < 1 || h < 1) return null;
const args = ['-t', 'jpeg', '-q', String(QUALITY)];
// grim otherwise renders at the largest scale of every monitor it can see, so
// on a mixed-DPI desk the size of a screenshot would depend on which other
// screens happen to be plugged in. Naming the scale also keeps it native:
// asking grim to resample is an order of magnitude slower than not.
// (-o would pin the scale too, but grim rejects it alongside -g.)
if (screen.scale) args.push('-s', String(screen.scale));
args.push('-g', `${Math.round(screen.x + left)},${Math.round(screen.y + top)} ${w}x${h}`, '-');
return run('grim', args);
}