// transcode.js — deriving the video rungs of a mip pyramid with the SYSTEM ffmpeg.
//
// ⛔ A SYSTEM BINARY, not a native addon, and that is the measured choice rather than a default.
// SOCIAL-MODEL §6.6.5: hypernote already ships this exact shape (`lib/media/video-processor.js`
// spawns `ffmpeg`), and `bare-ffmpeg`'s android-arm64 prebuild is 29 MB against an APK that
// hyper-social #39 fought from 527 MB down to 117 MB. It is "a capability purchase, not a
// requirement" — buy it where the ladder is actually produced, which today is a server.
//
// ⛔ SO THE PYRAMID IS ALLOWED TO BE PARTIAL (§6.6.4). A phone posts one level; a peer that HAS an
// encoder derives the rest later. That is why every level records who made it: a level this file
// produces is `derived:`, never the author's, and §8's "never re-encode a record you did not author"
// is the rule it exists to keep enforceable.
//
// ⚠ Video is the case §6.6.5 says still needs a decision on handsets. This solves the server case
// only, and says so rather than implying the whole problem is closed.
import { spawn } from 'bare-subprocess'
import fs from '#node:fs'
import { join } from '#node:path'
import os from '#node:os'
import process from '#node:process'
// ⛔ INJECTABLE, like every other module in this fleet that touches the outside: a routing surface
// is testable with no port bound because the network is a parameter, and spawning is the same kind
// of edge. It also matters concretely — a test that really spawns ffmpeg passes and then holds the
// runner open for ever, so a suite containing one cannot be the suite people run.
//
// ⚠ The projection layer is deliberately not named here, even in prose: test/boundary.test.js scans
// this directory for its vocabulary, and it caught this comment. That is the test working.
export const systemRun = (cmd, args) => new Promise((resolve) => {
let out = ''
let err = ''
let proc
try {
proc = spawn(cmd, args)
} catch (e) {
// ⛔ A MISSING BINARY IS AN ANSWER, not a crash. A node without ffmpeg simply cannot derive a
// level, and the caller must be able to say that plainly.
return resolve({ code: -1, out: '', err: 'cannot run ' + cmd + ': ' + ((e && e.message) || e) })
}
proc.stdout && proc.stdout.on('data', (c) => { out += c })
proc.stderr && proc.stderr.on('data', (c) => { err += c })
proc.on('error', (e) => resolve({ code: -1, out, err: (e && e.message) || String(e) }))
// ⚠ `close`, not `exit`. exit fires when the child dies; close fires once its stdio has drained,
// and resolving on the earlier one left the streams open — the suite passed and then hung for
// five minutes with nothing to say it had finished.
proc.on('close', (code) => {
// ⛔ DESTROY THE PIPES. A closed child still leaves its stdio handles registered with the loop,
// and bare will not exit while they are open — the suite passed and then hung, which reads
// exactly like a failure and is not one.
for (const st of [proc.stdout, proc.stderr, proc.stdin]) { try { st && st.destroy() } catch {} }
resolve({ code, out, err })
})
})
// ⛔ ENV-OVERRIDABLE, AND READ PER CALL. `rpc.js` invokes `available()`, `probe(path)` and
// `encodeLevel(path, level)` with no options, so every caller takes these defaults. On a server that
// is correct — the binary is on PATH. An APK has no PATH: its bundled binary sits at an absolute
// path the app only learns at runtime (the extracted native-lib dir), so without an override there
// is no way to name it and the node can never derive a rung however many binaries it ships.
//
// ⚠ Read INSIDE the function, 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, which is the "env before any import that reads it" trap favela-android's
// node.js already documents.
//
// ⚠ Absent or empty falls back to the previous literals, byte for byte. The three hosts running this
// daemon must see no change.
const ffmpegBin = () => process.env.FAVELA_FFMPEG || 'ffmpeg'
const ffprobeBin = () => process.env.FAVELA_FFPROBE || 'ffprobe'
export async function available ({ ffmpegPath = ffmpegBin(), ffprobePath = ffprobeBin(), run = systemRun } = {}) {
const a = await run(ffmpegPath, ['-version'])
const b = await run(ffprobePath, ['-version'])
return a.code === 0 && b.code === 0
}
/** `{ width, height, duration }` or null. Never throws. */
export async function probe (path, { ffprobePath = ffprobeBin(), run = systemRun } = {}) {
const r = await run(ffprobePath, [
'-v', 'error', '-select_streams', 'v:0',
'-show_entries', 'stream=width,height', '-show_entries', 'format=duration',
'-of', 'json', path
])
if (r.code !== 0) return null
try {
const j = JSON.parse(r.out)
const s = (j.streams && j.streams[0]) || {}
const width = Number(s.width) || 0
const height = Number(s.height) || 0
// ⛔ NO VIDEO STREAM IS NOT A ZERO-SIZED VIDEO. Returning {width:0,height:0} let an audio file or
// a corrupt container look like a probe that succeeded, and planLevels would then answer "no
// rungs" for the same reason it answers that for a tiny clip — two very different facts wearing
// one shape.
if (!width || !height) return null
return { width, height, duration: Number(j.format && j.format.duration) || 0 }
} catch { return null }
}
/**
* Encode ONE rung. Resolves `{ path, width, height }` or null — never throws.
*
* ⚠ `-2` on the width keeps the aspect and rounds to an even number, which h264 requires; asking
* for an odd width fails at encode time with a message about chroma that reads like a corrupt file.
*/
export async function encodeLevel (srcPath, level, { ffmpegPath = ffmpegBin(), dir = null, run = systemRun, stat = null } = {}) {
const out = join(dir || os.tmpdir(), 'favela-level-' + level.name + '-' + Date.now() + '.mp4')
const r = await run(ffmpegPath, [
'-y', '-i', srcPath,
'-vf', 'scale=-2:' + level.edge,
'-c:v', 'libx264', '-profile:v', level.profile,
'-b:v', level.bitrate, '-maxrate', level.maxrate, '-bufsize', level.maxrate,
'-c:a', 'aac', '-b:a', '128k',
// faststart so a player can begin before the whole file arrives — the same reason HLS exists.
'-movflags', '+faststart',
out
])
if (r.code !== 0) { try { fs.unlinkSync(out) } catch {}; return null }
let size = 0
try { size = stat ? stat(out) : fs.statSync(out).size } catch { return null }
if (!size) { try { fs.unlinkSync(out) } catch {}; return null }
return { path: out, width: 0, height: level.edge, size }
}
export default { available, probe, encodeLevel, systemRun }