Merge pull request #251 from screentinker/fix/brightsign-native-screenshot

A BrightSign photographs itself, using BrightSign's own API
This commit is contained in:
screentinker 2026-08-07 17:35:59 -05:00 committed by GitHub
commit f17eaed8bc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 193 additions and 0 deletions

View file

@ -669,6 +669,89 @@
* DWS writes the full capture to disk before returning a thumbnail), the caller gets a reason
* it can show instead of a spinner that never resolves.
*/
/*
* Capture the screen using BrightSign's OWN screenshot API the composite of the video and
* graphics layers, which is the whole point: an in-page canvas cannot read the hardware video
* plane, so a DOM composite returns a frame with the content missing.
*
* Entirely page-side, and that is what makes it work here. The obvious route was to ask the
* host (BrightScript) to capture via the player's DWS, but page->host messaging is dead after
* load on this platform, so the request never arrived. `@brightsign/screenshot` needs no host,
* no DWS, no messageport just the Node `require` the widget already has (the same one that
* makes `module` visible to classic scripts).
*
* The API writes a FILE rather than returning bytes, so it is read straight back with Node's
* fs available for exactly the same reason require() is.
*/
captureScreen: function (opts) {
var o = opts || {};
return new Promise(function (resolve, reject) {
var ScreenshotClass = tryRequire('@brightsign/screenshot');
var fs = tryRequire('fs');
if (!ScreenshotClass) { reject(new Error('no @brightsign/screenshot module')); return; }
if (!fs) { reject(new Error('no fs module')); return; }
// RAM FIRST, deliberately. The remote-control view drives this once a second, and a
// screenshot per second written to the boot flash is a wear-out mechanism with no upside —
// the file is read back and deleted microseconds later, so it never needs to be durable.
// BrightSign exposes tmp as a RAM volume alongside the storage ones. Real storage is only
// a fallback for a unit that does not present tmp, and the directory must already exist or
// the capture fails, so each candidate is checked rather than assumed.
var dirs = ['/storage/tmp', '/tmp', '/storage/ssd', '/storage/usb1', '/storage/sd', '/storage/flash'];
var dir = null;
for (var i = 0; i < dirs.length; i++) {
try { if (fs.existsSync(dirs[i])) { dir = dirs[i]; break; } } catch (e) { /* keep looking */ }
}
if (!dir) { reject(new Error('no writable volume for the capture')); return; }
var path = dir + '/st-capture.jpg';
try { fs.unlinkSync(path); } catch (e) { /* first run, or already gone */ }
var params = {
destinationFileName: path,
fileName: path, // deprecated alias, still honoured on older firmware
fileType: 'JPEG',
width: o.width || 960,
height: o.height || 540,
quality: o.quality || 70,
rotation: 0,
};
var shot;
try { shot = new ScreenshotClass(); } catch (e) { reject(new Error('screenshot object: ' + e.message)); return; }
try {
// syncCapture may interrupt on-screen operations, which the docs flag as a debugging
// trait — but it guarantees the file exists when it returns, and an operator asking for
// one screenshot is worth a single frame of interruption. The stream path uses async.
if (o.async && typeof shot.asyncCapture === 'function') shot.asyncCapture(params);
else if (typeof shot.syncCapture === 'function') shot.syncCapture(params);
else if (typeof shot.asyncCapture === 'function') shot.asyncCapture(params);
else { reject(new Error('screenshot object exposes neither capture method')); return; }
} catch (e) { reject(new Error('capture failed: ' + e.message)); return; }
// Poll for the file rather than trusting a return value: sync and async differ, and the
// documented contract is "a file appears", not "a promise settles".
var waited = 0;
var tick = function () {
var st = null;
try { st = fs.statSync(path); } catch (e) { st = null; }
if (st && st.size > 512) {
var b64;
try { b64 = fs.readFileSync(path).toString('base64'); }
catch (e) { reject(new Error('could not read the capture: ' + e.message)); return; }
try { fs.unlinkSync(path); } catch (e) { /* best-effort: never let cleanup fail a good capture */ }
resolve('data:image/jpeg;base64,' + b64);
return;
}
waited += 150;
if (waited > (o.timeoutMs || 8000)) { reject(new Error('capture produced no file in ' + waited + 'ms')); return; }
global.setTimeout(tick, 150);
};
global.setTimeout(tick, 150);
});
},
requestSnapshot: function (opts) {
var o = opts || {};
return new Promise(function (resolve, reject) {

View file

@ -4001,6 +4001,34 @@
// Falls through to the canvas path on ANY failure (no host, no primary storage, timeout):
// a partial screenshot showing images and widgets beats none, and the probe makes the
// video's absence explicit rather than silent.
// BrightSign's OWN capture API first: it composites the video and graphics layers, which is
// the one thing this player cannot do for itself — a canvas cannot read the hardware video
// plane, so a DOM composite comes back with the content missing. It needs no host bridge at
// all, which is what makes it work where the host route does not: page->host messaging is
// dead after load on that platform, so a request relayed through BrightScript never arrives.
if (BS && typeof BS.captureScreen === 'function') {
BS.captureScreen({ width: 960, height: 540 })
.then((dataUrl) => {
const base64 = String(dataUrl).split(',')[1];
if (base64 && base64.length > 100) {
socket.emit('device:screenshot', { device_id: config.deviceId, image_b64: base64 });
console.log('[bs] native screenshot sent:', base64.length, 'chars');
} else { captureAndSendCanvas(); }
})
.catch((err) => {
console.warn('[bs] native capture unavailable (' + err.message + ') — trying the host');
hostSnapshotOrCanvas();
});
return;
}
hostSnapshotOrCanvas();
}
// The older route: ask the HOST to capture through the player's DWS. Kept as a fallback for
// firmware where the native module is absent, though on the hardware this was debugged against
// the request never reaches the host at all.
function hostSnapshotOrCanvas() {
if (BS && typeof BS.requestSnapshot === 'function' && BS.hasHost()) {
BS.requestSnapshot({ width: 960, height: 540 })
.then((dataUrl) => {

View file

@ -0,0 +1,82 @@
'use strict';
/*
* A BrightSign now photographs itself with BrightSign's own API, and these tests pin the parts of
* that which are easy to undo by accident.
*
* The player could never capture its own screen: video decodes onto a hardware plane the DOM
* cannot read, so a canvas composite comes back with the content missing the panel reported
* "Video is playing on the hardware plane and cannot be captured" while playing perfectly.
*
* The long way round was to ask the HOST to capture through the player's DWS. That route is a dead
* end on this hardware: page->host messaging stops working after page load, so the request never
* arrives. `@brightsign/screenshot` composites the video and graphics layers and needs no host at
* all which is precisely why it works. It is reached through the same Node `require` that the
* widget already exposes (the one that also makes `module` visible to classic scripts, which is
* what broke the shared UMD modules on this platform).
*/
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const ROOT = path.join(__dirname, '..', '..');
const read = (p) => fs.readFileSync(path.join(ROOT, p), 'utf8');
const BRIDGE = read('brightsign/st-bridge.js');
const PLAYER = read('server/player/index.html');
test('#BS-capture: the bridge uses BrightSign\'s own screenshot API', () => {
assert.match(BRIDGE, /captureScreen:\s*function/, 'the bridge must expose captureScreen');
assert.match(BRIDGE, /tryRequire\('@brightsign\/screenshot'\)/, 'must load the native module');
// Both capture methods are documented; either is acceptable, but one must be called.
assert.match(BRIDGE, /syncCapture|asyncCapture/);
});
test('#BS-capture: it writes to RAM, not to the boot flash', () => {
// The remote-control view drives this once a second. A screenshot per second written to flash is
// a wear-out mechanism with no upside — the file is read back and deleted immediately, so it
// never needs to be durable.
const block = BRIDGE.slice(BRIDGE.indexOf('captureScreen:'), BRIDGE.indexOf('requestSnapshot:'));
const dirs = block.match(/var dirs = \[([^\]]+)\]/);
assert.ok(dirs, 'candidate directories not found');
const list = dirs[1].split(',').map((d) => d.trim().replace(/'/g, ''));
assert.ok(/tmp/.test(list[0]), `RAM must be tried first, got ${list[0]}`);
assert.ok(list.some((d) => /flash/.test(d)), 'real storage should still be a fallback');
assert.ok(list.indexOf(list.find((d) => /flash/.test(d))) > 0, 'flash must never be the first choice');
});
test('#BS-capture: the temp file is removed after it is read', () => {
const block = BRIDGE.slice(BRIDGE.indexOf('captureScreen:'), BRIDGE.indexOf('requestSnapshot:'));
assert.match(block, /unlinkSync/, 'a capture per second must not accumulate files');
assert.match(block, /toString\('base64'\)/, 'the bytes must come back as base64 for the socket');
});
test('#BS-capture: a missing module or file fails cleanly rather than hanging', () => {
const block = BRIDGE.slice(BRIDGE.indexOf('captureScreen:'), BRIDGE.indexOf('requestSnapshot:'));
assert.match(block, /no @brightsign\/screenshot module/, 'absent module must reject, not throw');
assert.match(block, /no fs module/);
assert.match(block, /timeoutMs/, 'the file poll must be bounded — a capture that never lands cannot wedge the player');
});
test('#BS-capture: the player tries the native API BEFORE the host route', () => {
// Order is the whole fix. The host route is a dead end on this hardware, so trying it first
// would spend the operator's patience on a 15s timeout before reaching the path that works.
const nativeAt = PLAYER.indexOf('BS.captureScreen');
const hostAt = PLAYER.indexOf('BS.requestSnapshot');
assert.ok(nativeAt > 0, 'the player must call captureScreen');
assert.ok(hostAt > 0, 'the host route should remain as a fallback');
assert.ok(nativeAt < hostAt, 'native capture must be attempted first');
});
test('#BS-capture: a native failure still falls back, never blanks', () => {
const seg = PLAYER.slice(PLAYER.indexOf('BS.captureScreen'), PLAYER.indexOf('function captureAndSendCanvas'));
assert.match(seg, /\.catch\(/, 'a rejected capture must be handled');
assert.match(seg, /hostSnapshotOrCanvas\(\)/, 'and fall through to the older routes');
assert.match(seg, /captureAndSendCanvas\(\)/, 'an empty result must still produce something');
});
test('#BS-capture: remote streaming inherits it — one capture path, not two', () => {
// startStreaming drives captureAndSend on a timer, so whatever the screenshot button gets, the
// live view gets. A second capture path would be a second, subtly different feature.
assert.match(PLAYER, /streamTimer = setInterval\(captureAndSend, 1000\)/);
});