// 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. // // The player sends its crop in CSS pixels, which are its own private unit: they // are device pixels divided by devicePixelRatio, and that ratio moves with // browser zoom and with fractional display scaling. The page cannot convert // them itself — screen.width is reported in a different unit by every engine, // and Wayland reports window.screenX as 0, so there is no offset to add. The // compositor knows both numbers exactly, so we ask it: hyprctl gives the // window's position and size in logical pixels, and the ratio of that to the // viewport the page reports is the scale factor, whatever the zoom happens to // be. That is what lets the player run in an ordinary window instead of // insisting on fullscreen. 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; } /** * The player's window, in the compositor's logical coordinate space. * * `title` is the page's own document.title, which every browser puts at the * front of its window title. Matched as a prefix rather than a substring so the * queue tab — deliberately titled the other way round — cannot be mistaken for * the player. If several tabs are somehow showing the player, the focused one * wins, then the largest: on a projector that is the one the party can see. * * Null when nothing matches, which the caller reports rather than guessing at, * since cropping against the wrong window photographs the wrong thing. */ async function playerWindow(title) { const out = await run('hyprctl', ['clients', '-j']); if (!out) return null; let clients = []; try { clients = JSON.parse(out.toString()); } catch { return null; } const matches = clients.filter( (client) => typeof client.title === 'string' && client.title.startsWith(title) && Array.isArray(client.at) && Array.isArray(client.size) && client.size[0] > 0 && client.size[1] > 0 && // A window on another workspace is not on the glass. Photographing its // coordinates would return whatever is actually there instead. client.mapped !== false && client.hidden !== true, ); if (matches.length === 0) return null; // Best first: the one actually on screen, then the most recently focused // (focusHistoryID counts up from 0 — there is no `focused` field on clients), // then the largest, which on a projector is the one the party can see. const rank = (client) => [ client.visible === false ? 1 : 0, Number.isFinite(client.focusHistoryID) ? client.focusHistoryID : Number.MAX_SAFE_INTEGER, -(client.size[0] * client.size[1]), ]; const [best] = matches.sort((a, b) => { const [ra, rb] = [rank(a), rank(b)]; for (let i = 0; i < ra.length; i += 1) { if (ra[i] !== rb[i]) return ra[i] - rb[i]; } return 0; }); return { x: best.at[0], y: best.at[1], width: best.size[0], height: best.size[1] }; } /** Whether this machine can be screenshotted at all. */ export async function available() { if (grimAvailable === null) { grimAvailable = (await run('grim', ['-h'])) !== null; } return grimAvailable; } /** * Where the page's viewport sits on screen, in logical pixels, given the * player's window and the viewport size the page reports in CSS pixels. * * The viewport spans the window's full width — browsers put no chrome down the * sides — so their ratio is the CSS-pixel scale outright. CSS pixels are square, * so that one number does for both axes, and whatever vertical room it does not * account for is the tab and address bars. Measuring the chrome rather than * assuming it is zero is what lets a windowed player work; fullscreen is then * just the case where the leftover happens to be nothing. */ export function viewportOrigin(window, viewportWidth, viewportHeight) { const scale = window.width / viewportWidth; const chrome = window.height - viewportHeight * scale; return { x: window.x, y: window.y + chrome, scale }; } /** * Photograph one rectangle of the player's viewport. * * `rect` is in CSS pixels, relative to the viewport — straight out of * getBoundingClientRect, with no fullscreen assumption behind it. `title` is the * page's document.title, used to find the window (see playerWindow). * * Resolves to a JPEG buffer, or null if grim is unusable, the window cannot be * found, or the rectangle falls outside the monitor. */ export async function capture({ x, y, width, height, viewportWidth, viewportHeight, title }) { if (!(await available())) return null; if (!(viewportWidth > 0 && viewportHeight > 0)) return null; const window = await playerWindow(title); if (!window) return null; const origin = viewportOrigin(window, viewportWidth, viewportHeight); // Into global logical pixels, the space grim's -g takes. const gx = origin.x + x * origin.scale; const gy = origin.y + y * origin.scale; const gw = width * origin.scale; const gh = height * origin.scale; // Which monitor the window is on, so a player on the projector is measured // against the projector and not the laptop panel. const screens = await monitors(); const centreX = window.x + window.width / 2; const centreY = window.y + window.height / 2; const screen = screens.find( (m) => centreX >= m.x && centreX < m.x + m.width && centreY >= m.y && centreY < m.y + m.height, ) ?? screens[0] ?? { x: 0, y: 0, scale: null, width: Infinity, height: Infinity }; // 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(screen.x, Math.min(gx, screen.x + screen.width)); const top = Math.max(screen.y, Math.min(gy, screen.y + screen.height)); const right = Math.max(left, Math.min(gx + gw, screen.x + screen.width)); const bottom = Math.max(top, Math.min(gy + gh, screen.y + 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)); // left/top are already global: the viewport origin was resolved against the // window's own position, which hyprctl reports in the same space. args.push('-g', `${Math.round(left)},${Math.round(top)} ${w}x${h}`, '-'); return run('grim', args); }