mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-19 08:33:56 -06:00
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:
parent
a87bd7d875
commit
4a4b9e343a
|
|
@ -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 };
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue