screentinker/server/lib/brightsign-snapshot-queue.js
ScreenTinker 1ec32197b2 Let a BrightSign host COLLECT its capture request over HTTP
Server side of the inverted capture path. The host half is not here — see the
end of this message.

Every other player is TOLD to capture: the server emits device:screenshot-request
over the device socket and the page photographs itself. A BrightSign cannot
photograph itself. Video decodes onto a hardware plane the DOM cannot read, so
an in-page canvas returns a frame with the content missing — which is why that
platform has been answering screenshot requests with a card explaining that the
video is uncapturable. Only the host, through the player's own DWS, can get a
real frame.

The obvious way to ask the host is through the page, and it does not work. On an
XT245 (BOS 9.1.93.2) page->host messaging is dead after load: instrumenting the
host to echo the `reason` of EVERY roHtmlWidgetEvent produced nothing at all
while the page was posting, though the boot-time probe round-trips. The registry
is not an alternative either — a running BrightScript does not observe registry
writes made by anyone else, proven by writing the key externally through the DWS
and watching the host ignore it.

What the host CAN do is HTTP; it already fetches its own package updates that
way. So the direction is inverted: the request waits here and the host collects
it. The image comes back over a plain POST, which means a capture will work even
when the page is wedged — exactly when an operator most wants to see the screen.

Held in memory on purpose. A capture request is worthless a minute after it was
made — someone clicked a button and is watching for the result — so persisting
it would only add a way to deliver a stale screenshot after a restart. Bounded
and TTL'd so a fleet going offline mid-request cannot grow it, and a repeat
request REPLACES rather than queues so a 1fps stream builds no backlog.

Authenticated with the same device_id + device_token pair the socket uses.
/api/brightsign/package is public because a player fetches it before it has any
identity; a screenshot is a picture of a customer's screen and belongs to one
display.

deviceSocket now exposes ONE ingestScreenshot() used by both the socket handler
and the HTTP route, so a BrightSign screenshot reaches the dashboard by exactly
the route every other player's does rather than becoming a second, subtly
different feature. Note those exports must be attached AFTER
`module.exports = function setupDeviceSocket`, which reassigns the object —
attaching above it silently wipes them, which cost a debugging round.

NOT INCLUDED, deliberately: the host-side poll. Adding it to autorun.brs's main
loop kills the BrightScript script within seconds of boot — the page keeps
playing, because the widget outlives the script, so from the dashboard it looks
healthy. Cause unidentified; BrightScript runtime faults do not reach
/api/v1/logs, so there is no error text to read. Half a feature that silently
takes down the host is worse than none, so the server waits for a host that can
safely ask.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014skWYXJUWhF73EvNPgB2AS
2026-08-07 14:15:06 -05:00

79 lines
3.6 KiB
JavaScript

'use strict';
/*
* Pending framebuffer-capture requests for BrightSign players, held for the host to collect.
*
* WHY THIS EXISTS, because it looks like a detour and is not:
*
* Every other player is TOLD to take a screenshot — the server emits `device:screenshot-request`
* over the device socket and the page captures itself. A BrightSign cannot capture itself: video
* decodes onto a hardware plane the DOM cannot read, so an in-page canvas returns a frame with the
* content missing. Only the host (BrightScript) can get a real capture, via the player's own DWS.
*
* The obvious route to the host is the page: st-bridge.js posts a message over the widget's
* messageport. On real hardware (XT245, BOS 9.1.93.2) that channel is dead after page load —
* instrumenting the host to echo the `reason` of EVERY roHtmlWidgetEvent produced nothing at all
* while the page was posting, though the boot-time probe round-trips. The registry is not an
* alternative either: a running BrightScript does not observe registry writes made by anyone else,
* including ones made externally through the DWS.
*
* What the host CAN do is HTTP — it already fetches its own package updates that way. So the
* direction is inverted: the request waits here, and the host collects it on the loop it is
* already running. The image comes back over a plain POST, so a capture works even when the page
* is wedged, which is exactly when an operator most wants to see the screen.
*
* Deliberately in memory. A capture request is worthless a minute after it was made — an operator
* clicked a button and is watching for the result — so persisting it would only add a way to
* deliver a stale screenshot after a restart.
*/
// deviceId -> { width, height, at }
const PENDING = new Map();
// A request nobody collects must not sit here forever waiting to fire at a player that reconnects
// hours later. Comfortably longer than the dashboard's own 15s patience, short enough that the
// answer still refers to what the operator was looking at.
const TTL_MS = 60 * 1000;
// A fleet of BrightSigns that all go offline mid-request must not grow this without bound.
const MAX_PENDING = 500;
function request(deviceId, opts) {
if (!deviceId) return false;
const o = opts || {};
if (!PENDING.has(deviceId) && PENDING.size >= MAX_PENDING) {
// Drop the OLDEST rather than refuse the newest: the newest is the one someone is watching for.
const oldest = PENDING.keys().next().value;
if (oldest !== undefined) PENDING.delete(oldest);
}
// Re-requesting replaces rather than queues. A dashboard polling the button, or a 1fps remote
// stream, must not build a backlog the host then works through long after anyone stopped looking.
PENDING.set(deviceId, {
width: Number(o.width) > 0 ? Math.min(3840, Math.round(o.width)) : 960,
height: Number(o.height) > 0 ? Math.min(2160, Math.round(o.height)) : 540,
at: Date.now(),
});
return true;
}
/* Collect and clear. Returns null when there is nothing pending or it has expired. */
function take(deviceId) {
const p = PENDING.get(deviceId);
if (!p) return null;
PENDING.delete(deviceId);
if (Date.now() - p.at > TTL_MS) return null;
return { width: p.width, height: p.height };
}
/* Drop anything expired. Called from the same sweep as the other bounded stores. */
function sweep(now) {
const t = now || Date.now();
let dropped = 0;
for (const [id, p] of PENDING) {
if (t - p.at > TTL_MS) { PENDING.delete(id); dropped++; }
}
return dropped;
}
module.exports = { request, take, sweep, TTL_MS, MAX_PENDING, _size: () => PENDING.size };