OverviewFilesCommitsIssuesDocsReleasesPeers

favela-peer commits / 3ca381b

0.0.51: the ffmpeg binaries are env-overridable, because an APK has no PATH
`rpc.js` calls `available()`, `probe(path)` and `encodeLevel(path, level)` with no options, so every caller takes the `'ffmpeg'`/`'ffprobe'` literals. On a server that is right — the binary is on PATH. An Android app has no PATH: a bundled binary lives at an absolute path the app only learns at runtime (its extracted native-lib dir), so there was no way to name it and a handset could never derive a rung however many binaries the APK shipped. `FAVELA_FFMPEG` / `FAVELA_FFPROBE` now name them. ⚠ READ PER CALL, not captured at import. The Android host sets these while booting the worklet, which happens after this module is imported — a value captured at module scope would be the default for ever. That is the same "env before any import that reads it" trap favela-android's node.js already carries a comment about. ⚠ Absent or empty falls back to the previous literals byte for byte, and the test asserts that branch first. The three hosts running this daemon see no change: 351/351 tests, 842/842 asserts. Written test-first — the override assertion failed while the default assertion passed, which is the failure that proves the test was measuring the right thing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
3ca381b74053b58e2477b868870e1001279d13fa
by plankatron <fleeky@gmail.com> · 2026-09-10T13:46:08.000Z

Changed files (3) +56 -4

lib/transcode.js +20 -3
……@@ -18,6 +18,7 @@ import { spawn } from 'bare-subprocess'
1818import fs from '#node:fs'
1919import { join } from '#node:path'
2020import os from '#node:os'
21import process from '#node:process'
2122
2223// ⛔ INJECTABLE, like every other module in this fleet that touches the outside: a routing surface
2324// is testable with no port bound because the network is a parameter, and spawning is the same kind
……@@ -52,14 +53,30 @@ export const systemRun = (cmd, args) => new Promise((resolve) => {
5253 })
5354})
5455
55export async function available ({ ffmpegPath = 'ffmpeg', ffprobePath = 'ffprobe', run = systemRun } = {}) {
56// ⛔ ENV-OVERRIDABLE, AND READ PER CALL. `rpc.js` invokes `available()`, `probe(path)` and
57// `encodeLevel(path, level)` with no options, so every caller takes these defaults. On a server that
58// is correct — the binary is on PATH. An APK has no PATH: its bundled binary sits at an absolute
59// path the app only learns at runtime (the extracted native-lib dir), so without an override there
60// is no way to name it and the node can never derive a rung however many binaries it ships.
61//
62// ⚠ Read INSIDE the function, not captured at import. The Android host sets these while booting the
63// worklet, which happens after this module is imported — a value captured at module scope would be
64// the default for ever, which is the "env before any import that reads it" trap favela-android's
65// node.js already documents.
66//
67// ⚠ Absent or empty falls back to the previous literals, byte for byte. The three hosts running this
68// daemon must see no change.
69const ffmpegBin = () => process.env.FAVELA_FFMPEG || 'ffmpeg'
70const ffprobeBin = () => process.env.FAVELA_FFPROBE || 'ffprobe'
71
72export async function available ({ ffmpegPath = ffmpegBin(), ffprobePath = ffprobeBin(), run = systemRun } = {}) {
5673 const a = await run(ffmpegPath, ['-version'])
5774 const b = await run(ffprobePath, ['-version'])
5875 return a.code === 0 && b.code === 0
5976}
6077
6178/** `{ width, height, duration }` or null. Never throws. */
62export async function probe (path, { ffprobePath = 'ffprobe', run = systemRun } = {}) {
79export async function probe (path, { ffprobePath = ffprobeBin(), run = systemRun } = {}) {
6380 const r = await run(ffprobePath, [
6481 '-v', 'error', '-select_streams', 'v:0',
6582 '-show_entries', 'stream=width,height', '-show_entries', 'format=duration',
……@@ -86,7 +103,7 @@ export async function probe (path, { ffprobePath = 'ffprobe', run = systemRun }
86103 * ⚠ `-2` on the width keeps the aspect and rounds to an even number, which h264 requires; asking
87104 * for an odd width fails at encode time with a message about chroma that reads like a corrupt file.
88105 */
89export async function encodeLevel (srcPath, level, { ffmpegPath = 'ffmpeg', dir = null, run = systemRun, stat = null } = {}) {
106export async function encodeLevel (srcPath, level, { ffmpegPath = ffmpegBin(), dir = null, run = systemRun, stat = null } = {}) {
90107 const out = join(dir || os.tmpdir(), 'favela-level-' + level.name + '-' + Date.now() + '.mp4')
91108 const r = await run(ffmpegPath, [
92109 '-y', '-i', srcPath,
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-peer",
3 "version": "0.0.50",
3 "version": "0.0.51",
44 "description": "The favela daemon \u2014 feeds and documents of markdown records over Hypercore. No atproto, no ActivityPub.",
55 "type": "module",
66 "scripts": {
test/transcode.test.js +35 -0
……@@ -6,6 +6,7 @@
66// run is worse than one that mocks a boundary, so the boundary is injected and the spawning test is
77// opt-in. It has been run and it works: 1280x720 in, a genuine 480-tall even-width file out.
88import test from 'brittle'
9import process from '#node:process'
910import { probe, encodeLevel, available } from '../lib/transcode.js'
1011
1112const ok = (out) => async () => ({ code: 0, out, err: '' })
……@@ -44,3 +45,37 @@ test('encodeLevel returns null when ffmpeg fails or writes nothing', async (t) =
4445 t.is(await encodeLevel('/src.mp4', { name: '480p', edge: 480, bitrate: '1k', maxrate: '1k', profile: 'baseline' },
4546 { run: ok(''), stat: () => 0 }), null, '⛔ and a zero-byte file is not a level')
4647})
48
49// ── the binaries are ENV-OVERRIDABLE, because a phone has no `ffmpeg` on any PATH ────────────────
50//
51// ⛔ WHY THIS EXISTS. `rpc.js` calls `available()`, `probe(path)` and `encodeLevel(path, level)`
52// with NO options, so every caller gets the `'ffmpeg'`/`'ffprobe'` defaults. On a server that is
53// right — the binary is on PATH. An Android app has no PATH: a bundled binary lives at an absolute
54// path the app learns at runtime (its extracted native-lib dir), so without an override there is no
55// way to reach it and the node can never derive a rung, however many binaries the APK ships.
56//
57// ⚠ DEFAULTS ARE UNCHANGED, deliberately. The three hosts running this daemon must see no
58// difference: absent env, this is byte-for-byte the previous behaviour.
59test('the ffmpeg binaries can be overridden by env, and default unchanged when it is absent', async (t) => {
60 const seen = []
61 const run = async (cmd) => { seen.push(cmd); return { code: 0, out: '', err: '' } }
62
63 // ⚠ '' not `delete`: bare-process's env is a proxy whose deleteProperty trap refuses. Empty is
64 // falsy, which is the same branch an unset var takes — so this tests the real fallback path.
65 process.env.FAVELA_FFMPEG = ''
66 process.env.FAVELA_FFPROBE = ''
67 await available({ run })
68 t.alike(seen.splice(0), ['ffmpeg', 'ffprobe'], 'absent env -> the previous defaults, unchanged')
69
70 process.env.FAVELA_FFMPEG = '/data/app/lib/arm64/libffmpeg.so'
71 process.env.FAVELA_FFPROBE = '/data/app/lib/arm64/libffprobe.so'
72 await available({ run })
73 t.alike(seen.splice(0), ['/data/app/lib/arm64/libffmpeg.so', '/data/app/lib/arm64/libffprobe.so'],
74 'env names the bundled binaries — the only way an APK can reach its own ffmpeg')
75
76 await probe('/tmp/x.mp4', { run })
77 t.is(seen.splice(0)[0], '/data/app/lib/arm64/libffprobe.so', 'probe honours it too')
78
79 process.env.FAVELA_FFMPEG = ''
80 process.env.FAVELA_FFPROBE = ''
81})