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\)/); +});