Files
simonandClaude Opus 5 0683acd69e 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
2026-08-10 22:49:36 +02:00

248 lines
8.7 KiB
JavaScript

// Screenshots of the video frame, from one of two backends.
//
// The decks are cross-origin YouTube iframes, so nothing on the page can read
// their pixels — canvas taints, html2canvas sees an empty box. Photographing
// the screen is the only way to get the actually-playing image.
//
// Preferred: the server takes the screenshot (see server/screen.js). It runs on
// the machine the projector is plugged into, so it can grab the region itself
// with no picker, no permission, and no "Sharing this tab" bar sitting over the
// party. The response is the finished crop, so `grab` hands back a URL and an
// <img> does the rest.
//
// Fallback: getDisplayMedia, for machines the server cannot screenshot. That
// path crops a live stream on a canvas, and carries all the caveats the server
// path removes — a picker, a sharing indicator, and on Firefox no tab capture
// at all, only whole windows and screens.
//
// Both need the page fullscreen, for the same reason: only then does a CSS
// pixel equal a screen pixel, and a shared window can never be mapped at all —
// it carries the tab bar and URL bar, and Wayland reports window.screenX as 0,
// so there is no offset to subtract.
// The server screenshots its own screen, which is the player's screen only when
// the two are the same machine. Opened from a laptop across the network, /shot
// would photograph the projector and crop the laptop's coordinates out of it.
const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '::1']);
/**
* Whether the server can screenshot for us. Probed at load rather than at the
* click, because getDisplayMedia needs the click's transient activation and
* cannot be reached through an await.
*/
let serverShots = null;
if (LOCAL_HOSTS.has(location.hostname)) {
// Any rect will do; only the status code is read.
fetch('/shot?x=0&y=0&w=2&h=2&sw=0&sh=0', { method: 'GET', cache: 'no-store' })
.then((response) => {
serverShots = response.status !== 501 && response.status !== 404;
})
.catch(() => {
serverShots = false;
});
}
/**
* Ask for a screenshot source. Resolves to a `{ grab, stop }` handle, or to
* null if there is none — callers degrade rather than fail.
*
* `onIssue` receives a human-readable reason whenever screenshots are
* unavailable or stop lining up; without it these failures are invisible.
*
* Must be called synchronously from a user gesture: the fallback's
* getDisplayMedia needs transient activation, so kick this off before any await
* and keep the promise.
*/
export function requestCapture({ onIssue = () => {} } = {}) {
if (serverShots) return Promise.resolve(openServerCapture(onIssue));
return requestDisplayMedia(onIssue);
}
/**
* True while the viewport covers the whole screen, which is the one case the
* crop maths can handle. Tested by dimensions rather than by
* `document.fullscreenElement`, because F11 fullscreen is browser-level and
* leaves that property null.
*/
function fillsScreen() {
return window.innerWidth === screen.width && window.innerHeight === screen.height;
}
/**
* Watch the viewport-covers-screen condition and report only its transitions:
* grab() runs as often as once per animation frame, and a stuck state would
* otherwise repeat the same notice forever.
*/
function trackAlignment(onIssue, initial) {
let mapped = initial;
if (!mapped) {
onIssue('Screenshots need the page fullscreen to line up with the screen — press F11.');
}
return function aligned() {
const now = fillsScreen();
if (now !== mapped) {
mapped = now;
onIssue(
mapped
? 'Screenshots are lined up again.'
: 'Screenshots paused: the page no longer fills the screen — press F11.',
);
}
return mapped;
};
}
// --- server screenshots -------------------------------------------------
function openServerCapture(onIssue) {
const aligned = trackAlignment(onIssue, fillsScreen());
let nonce = 0;
return {
/**
* A URL for a fresh photograph of `rect`, in CSS pixels. Safe to hand
* straight to an <img>: the server returns the crop itself, already sized.
* Null while the viewport does not cover the screen.
*/
grab({ x, y, width, height }) {
if (!aligned()) return null;
if (width <= 0 || height <= 0) return null;
const query = new URLSearchParams({
x: Math.round(x),
y: Math.round(y),
w: Math.round(width),
h: Math.round(height),
sw: screen.width,
sh: screen.height,
// The server sends no-store, but a nonce also keeps two stills taken at
// the same spot from collapsing into one request in flight.
n: String(++nonce),
});
return `/shot?${query}`;
},
// Nothing is held open: each screenshot is one request.
stop() {},
};
}
// --- getDisplayMedia fallback -------------------------------------------
function requestDisplayMedia(onIssue) {
// Undefined on a plain-HTTP origin, which is exactly how the README tells
// people to reach this page from other machines on the network.
if (!navigator.mediaDevices?.getDisplayMedia) {
onIssue('Screen capture needs a secure origin (localhost or HTTPS) — running without screenshots.');
return Promise.resolve(null);
}
try {
return navigator.mediaDevices
.getDisplayMedia({
video: { frameRate: 8 },
audio: false,
// Chrome-only, and only a hint: it preselects this tab, whose stream
// matches the viewport exactly. Firefox drops the unknown member
// silently and offers windows and screens instead — hence the surface
// check in attach().
preferCurrentTab: true,
})
.then((stream) => attach(stream, onIssue))
.catch((error) => {
onIssue(describe(error));
return null;
});
} catch (error) {
// Some rejections arrive synchronously. Never let them escape into
// start(), which would abort playback before the gate even lifts.
onIssue(describe(error));
return Promise.resolve(null);
}
}
function describe(error) {
const name = error?.name ?? 'Error';
if (name === 'NotAllowedError') {
return 'Screen sharing declined — the animations run without screenshots.';
}
if (name === 'NotFoundError' || name === 'AbortError') {
return 'No screen was shared — the animations run without screenshots.';
}
return `Screen capture failed (${name}: ${error?.message || 'no detail'}) — running without screenshots.`;
}
async function attach(stream, onIssue) {
const [track] = stream.getVideoTracks();
if (!track) {
onIssue('The shared stream carried no video — running without screenshots.');
return null;
}
// 'browser' is Chrome's tab capture: the stream is the viewport by
// definition, so it lines up whether or not the page is fullscreen.
const surface = track.getSettings?.().displaySurface ?? null;
if (surface === 'window') {
onIssue('Sharing a single window cannot be cropped — restart and share the whole screen instead.');
}
const aligned =
surface === 'browser'
? () => true
: trackAlignment(onIssue, surface !== 'window' && fillsScreen());
const video = document.createElement('video');
video.srcObject = stream;
video.muted = true;
video.playsInline = true;
// Must be in the document, like #probe: a detached video is never composited
// and the browser throttles its frames hard, which would leave screenshots
// showing a moment several seconds stale. 1x1 and transparent, so it costs
// nothing and cannot show up in its own crop.
video.id = 'captureSink';
document.body.appendChild(video);
await video.play().catch(() => {});
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
return {
/**
* Crop a viewport rect out of the live stream. `rect` is in CSS pixels.
* Returns a data URL, or null while the stream has yet to deliver a frame
* or while the viewport cannot be located inside the captured surface.
*/
grab({ x, y, width, height }) {
if (!aligned()) return null;
if (video.readyState < 2 || !video.videoWidth) return null;
if (width <= 0 || height <= 0) return null;
const scaleX = video.videoWidth / window.innerWidth;
const scaleY = video.videoHeight / window.innerHeight;
canvas.width = Math.round(width * scaleX);
canvas.height = Math.round(height * scaleY);
context.drawImage(
video,
x * scaleX,
y * scaleY,
width * scaleX,
height * scaleY,
0,
0,
canvas.width,
canvas.height,
);
return canvas.toDataURL('image/jpeg', 0.85);
},
stop() {
for (const t of stream.getTracks()) t.stop();
video.srcObject = null;
video.remove();
},
};
}