// 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); }