mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 13:53:12 -06:00
Merge pull request #251 from screentinker/fix/brightsign-native-screenshot
A BrightSign photographs itself, using BrightSign's own API
This commit is contained in:
commit
f17eaed8bc
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
82
server/test/brightsign-native-capture.test.js
Normal file
82
server/test/brightsign-native-capture.test.js
Normal 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\)/);
|
||||
});
|
||||
Loading…
Reference in a new issue