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:
@@ -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 });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user