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
153 lines
5.2 KiB
JavaScript
153 lines
5.2 KiB
JavaScript
// 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);
|
|
}
|