From 4a4b9e343a529010b38c0a8977dbb40afd1a245a Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Tue, 18 Aug 2026 20:12:26 -0500 Subject: [PATCH] brightsign: stage our own ffprobe/ffmpeg into /tmp so media tools work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_014kfhrUPit5MCqxeTQyqr56 --- brightsign/server/bs-server-boot.js | 73 +++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/brightsign/server/bs-server-boot.js b/brightsign/server/bs-server-boot.js index cd340af..30816c8 100644 --- a/brightsign/server/bs-server-boot.js +++ b/brightsign/server/bs-server-boot.js @@ -367,8 +367,79 @@ process.on('unhandledRejection', (e) => remember('error', ['UNHANDLED REJECTION' 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() { try { + stageMediaTools(); require('./server/server.js'); } catch (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)]); } + + module.exports = { status };