brightsign: stage our own ffprobe/ffmpeg into /tmp so media tools work

The server logged "[MEDIA] ffmpeg, ffprobe not found on PATH — video thumbnails
and durations are DISABLED" on every boot of a player-hosted server. It now says
"found — video thumbnails enabled", because the binaries are shipped gzipped,
unpacked into /tmp at startup and put on PATH before server.js is required (its
probe looks them up by name with execFile, so PATH is the whole mechanism).

/tmp is not laziness, it is the only option, and the alternatives were measured on
an XT245 rather than assumed:

  /storage/ssd    bsexfat  rw,nosuid,nodev,noexec,...     <- exec => EACCES
  /storage/flash  ext4     rw,nosuid,nodev,noexec,...     <- same
  /storage/tmp    tmpfs    rw,nosuid,nodev,noexec,...     <- same
  /tmp            tmpfs    rw,relatime                    <- the one that permits exec

We run as uid=994(nodejs), so `mount -o remount,exec` answers "permission denied
(are you root?)" on both volumes, and there is no setuid path to it: BrightSign
points mount at busybox.nosuid, and their busybox.suid carries login/passwd/vlock
and no mount applet. A symlink does not help either — noexec is a property of the
filesystem holding the inode, not of the path used to reach it, so a link in /tmp
pointing at flash still fails EACCES. Copying is what moves the inode onto a
filesystem that permits execution.

The binaries are ours and deliberately link nothing of BrightSign's. The OS does
ship the whole ffmpeg 5.1 stack (libavformat/libavcodec/... backing GStreamer) and
a stock Debian ffprobe against those libs starts, prints its banner, and then
SIGSEGVs the moment it opens a file — their Yocto build is patched for hardware
decode. So these are cross-built FFmpeg 7.1.1, --disable-gpl (LGPL 2.1+), fully
static, --enable-small. ffprobe carries no decoders at all (durations and geometry
come from the container) which is why it is 1.8MB against ffmpeg's 4.9MB; ~6.7MB of
tmpfs on a box with 2.8GB free, ~3.2MB gzipped at rest.

Verified on XT245 URD3C6000823: unpack 19ms, ffprobe -version 9ms,
format=duration 3.533333 against a 3.533333s clip, stream 320x240 h264.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014kfhrUPit5MCqxeTQyqr56
This commit is contained in:
ScreenTinker 2026-08-18 20:12:26 -05:00
parent a87bd7d875
commit 4a4b9e343a

View file

@ -367,8 +367,79 @@ process.on('unhandledRejection', (e) => remember('error', ['UNHANDLED REJECTION'
const SERVER_ENTRY = path.join(__dirname, 'server', 'server.js'); const SERVER_ENTRY = path.join(__dirname, 'server', 'server.js');
/*
* ---------------------------------------------------------------------------------------------
* Media tools (ffprobe/ffmpeg) staged into /tmp, then put on PATH.
* ---------------------------------------------------------------------------------------------
*
* Without this the server logs, on every boot:
*
* [MEDIA] ffmpeg, ffprobe not found on PATH video thumbnails and durations are DISABLED
*
* /tmp IS NOT LAZINESS, IT IS THE ONLY OPTION. Every writable volume on this player is mounted
* noexec the exFAT SSD, the ext4 flash, even /storage/tmp and we run as uid 994 (nodejs), so
* `mount -o remount,exec` answers "permission denied (are you root?)". A symlink does not help
* either: noexec is a property of the filesystem holding the inode, not of the path used to reach
* it, so a link in /tmp pointing at flash still fails with EACCES. Copying is what moves the inode
* onto a filesystem that permits execution. /tmp is tmpfs and is the one writable place without
* noexec, so the binaries are unpacked there at every boot. They are shipped gzipped because the
* unpacked pair is ~7MB of RAM and the compressed pair is ~3MB on disk.
*
* THE BINARIES MUST BE OURS. BrightSignOS ships the whole ffmpeg 5.1 shared-library stack
* (libavformat/libavcodec/...) to back GStreamer, and a stock Debian ffprobe linked against them
* starts, prints its banner and then SIGSEGVs the instant it opens a file, because their Yocto
* build is patched for hardware decode. So these are cross-built here, --disable-gpl (LGPL 2.1+),
* fully static, linking nothing of theirs. ffprobe carries no decoders at all: durations and stream
* geometry come from the container, which is why it is 1.8MB against ffmpeg's 5MB.
*/
const MEDIA_BIN_DIR = '/tmp/screentinker-bin';
function stageMediaTools() {
const zlib = require('zlib');
const staged = [];
try {
fs.mkdirSync(MEDIA_BIN_DIR, { recursive: true });
for (const name of ['ffprobe', 'ffmpeg']) {
// Shipped in the payload for a real install; DATA_DIR is the fallback so a device can be
// given the tools without recutting the package.
const candidates = [path.join(__dirname, 'bin', name + '.gz'),
path.join(DATA_DIR, 'bin', name + '.gz')];
const src = candidates.find((c) => fs.existsSync(c));
if (!src) continue;
const bytes = zlib.gunzipSync(fs.readFileSync(src));
const dest = path.join(MEDIA_BIN_DIR, name);
fs.writeFileSync(dest, bytes);
fs.chmodSync(dest, 0o755);
staged.push(name + ' ' + Math.round(bytes.length / 1024) + 'KB');
}
if (!staged.length) {
console.log('[media] no bundled ffprobe/ffmpeg found — thumbnails and durations stay disabled');
return;
}
// The server probes for these BY NAME with execFile, so the directory has to be on PATH before
// server.js is required. That is the whole reason this runs where it does.
process.env.PATH = MEDIA_BIN_DIR + (process.env.PATH ? ':' + process.env.PATH : '');
console.log('[media] staged ' + staged.join(', ') + ' into ' + MEDIA_BIN_DIR + ' (on PATH)');
// Prove it actually RUNS, and put the banner on the diagnostic screen and the serial console.
// Asynchronous: a hung binary must not hold up the server, and this is only reporting.
require('child_process').execFile(path.join(MEDIA_BIN_DIR, 'ffprobe'), ['-hide_banner', '-version'],
{ timeout: 10000, encoding: 'utf8' }, (err, stdout) => {
if (err) {
console.warn('[media] staged ffprobe did not run: ' + (err && err.message));
return;
}
console.log('[media] ' + String(stdout).split('\n')[0]);
});
} catch (e) {
// Never fatal. A player with no thumbnails is worth more than a player that would not boot.
console.warn('[media] could not stage media tools: ' + (e && e.message ? e.message : e));
}
}
function startServer() { function startServer() {
try { try {
stageMediaTools();
require('./server/server.js'); require('./server/server.js');
} catch (e) { } catch (e) {
remember('error', ['server failed to start', e && e.stack ? e.stack : String(e)]); remember('error', ['server failed to start', e && e.stack ? e.stack : String(e)]);
@ -489,4 +560,6 @@ try {
remember('error', ['could not start the status listener', String(e && e.message ? e.message : e)]); remember('error', ['could not start the status listener', String(e && e.message ? e.message : e)]);
} }
module.exports = { status }; module.exports = { status };