From 4b6194884bb015bee9cae2036d4bdfb513f73c3c Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Fri, 7 Aug 2026 17:28:00 -0500 Subject: [PATCH] A BrightSign photographs itself, using BrightSign's own API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This platform has never been able to screenshot itself. Video decodes onto a hardware plane the DOM cannot read, so an in-page 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. @brightsign/screenshot composites the video and graphics layers, which is exactly the thing a canvas cannot do. It is reached through the same Node require() 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. The same quirk caused that bug and enables this fix. WHY THIS WORKS WHERE THE LONG WAY ROUND DID NOT. The obvious route was to ask the HOST to capture through the player's own DWS, because BrightScript can reach it. That is a dead end here: page->host messaging stops working after page load, so the request never arrives — instrumenting the host to echo the reason of EVERY roHtmlWidgetEvent produced nothing at all while the page was posting. This API needs no host, no messageport and no DWS, so none of that is in the path. The host route stays as a fallback for firmware without the module, but it is no longer how this works. The API writes a FILE rather than returning bytes, so it is read straight back with Node's fs and sent over the socket the player already has. TO RAM, NOT TO FLASH. 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 nothing to show for it: the file is read back and deleted microseconds later, so it never needs to be durable. tmp is tried first and real storage only as a fallback for a unit that does not present it. The directory must already exist or the capture fails, so each candidate is checked rather than assumed. Ordering is part of the fix: the native API is tried BEFORE the host route, because trying the dead end first would spend an operator's patience on a 15s timeout before reaching the path that works. Every failure still falls through to the canvas, so a capture never comes back blank. Remote streaming inherits all of it — startStreaming already drives the same captureAndSend — so the live view now shows real video rather than a card explaining why it cannot. Verified on the hardware: a real 960x540 frame of the playing video, captured by the player, delivered to the dashboard over its own socket. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014skWYXJUWhF73EvNPgB2AS --- brightsign/st-bridge.js | 83 +++++++++++++++++++ server/player/index.html | 28 +++++++ server/test/brightsign-native-capture.test.js | 82 ++++++++++++++++++ 3 files changed, 193 insertions(+) create mode 100644 server/test/brightsign-native-capture.test.js diff --git a/brightsign/st-bridge.js b/brightsign/st-bridge.js index c872075..e9659af 100644 --- a/brightsign/st-bridge.js +++ b/brightsign/st-bridge.js @@ -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) { diff --git a/server/player/index.html b/server/player/index.html index a695560..edd56cc 100644 --- a/server/player/index.html +++ b/server/player/index.html @@ -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) => { diff --git a/server/test/brightsign-native-capture.test.js b/server/test/brightsign-native-capture.test.js new file mode 100644 index 0000000..f38eb2a --- /dev/null +++ b/server/test/brightsign-native-capture.test.js @@ -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\)/); +});