mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 23:25:18 +00:00
149 lines
5.7 KiB
JavaScript
149 lines
5.7 KiB
JavaScript
/**
|
|
* Generic external-binary plumbing of the CLI tools: a process runner that
|
|
* never throws, a fail-closed capability probe and the ffmpeg resolution
|
|
* (`FFMPEG_PATH` → `ffmpeg` on PATH → the registry-packaged
|
|
* `@ffmpeg-installer/ffmpeg` binary). Extracted from the reel composer (C196)
|
|
* so the web-speech ingestion (C215) reuses the same owner instead of a second
|
|
* resolver.
|
|
*
|
|
* Resolution order (see `docs/plans/reels-tutoriais-impl.md`, Decisão 2): the
|
|
* packed fallback keeps the non-technical operator flow working on a
|
|
* workstation without a system ffmpeg; the homeserver Docker build installs it
|
|
* from npm (never from GitHub releases, which the homeserver cannot reach — see
|
|
* the sharp override note in `pnpm-workspace.yaml`).
|
|
*/
|
|
|
|
import { execFile } from 'node:child_process'
|
|
import { promisify } from 'node:util'
|
|
|
|
const execFileAsync = promisify(execFile)
|
|
const DEFAULT_MAX_BUFFER_BYTES = 16 * 1024 * 1024
|
|
|
|
/**
|
|
* @typedef {{ ok: boolean, stdout: string, stderr: string, error?: { code?: string } }} RunResult
|
|
* @typedef {(bin: string, args: string[]) => Promise<RunResult>} RunFn
|
|
*/
|
|
|
|
/**
|
|
* Runs a process and returns the exit outcome instead of throwing.
|
|
*
|
|
* @type {(bin: string, args: string[]) => Promise<RunResult>}
|
|
*/
|
|
export const runProcess = async (bin, args) => {
|
|
try {
|
|
const { stdout, stderr } = await execFileAsync(bin, args, {
|
|
maxBuffer: DEFAULT_MAX_BUFFER_BYTES,
|
|
})
|
|
return { ok: true, stdout, stderr }
|
|
} catch (error) {
|
|
return { ok: false, stdout: error.stdout ?? '', stderr: error.stderr ?? '', error }
|
|
}
|
|
}
|
|
|
|
const defaultRun = runProcess
|
|
|
|
const versionLine = (stdout) =>
|
|
stdout.split('\n').find((line) => line.startsWith('ffmpeg version')) ?? ''
|
|
|
|
/**
|
|
* Capability probe: the binary must run and ship every required encoder and
|
|
* filter. Missing capabilities are reported, never assumed.
|
|
*
|
|
* @param {string} bin
|
|
* @param {{ run?: RunFn, audio?: boolean, encoders?: string[], filters?: string[] }} [options]
|
|
* @returns {Promise<{ ok: true, bin: string, version: string } | { ok: false, bin: string, reason: string }>}
|
|
*/
|
|
export const probeFfmpeg = async (
|
|
bin,
|
|
{ run = defaultRun, audio = false, encoders = [], filters = [] } = {},
|
|
) => {
|
|
const version = await run(bin, ['-hide_banner', '-version'])
|
|
if (!version.ok) {
|
|
return { ok: false, bin, reason: `não executou (${version.error?.code ?? 'erro'})` }
|
|
}
|
|
|
|
const requiredEncoders = [...encoders, ...(audio ? ['libmp3lame'] : [])]
|
|
if (requiredEncoders.length > 0) {
|
|
const listed = await run(bin, ['-hide_banner', '-encoders'])
|
|
const missing = requiredEncoders.filter(
|
|
(encoder) => !listed.ok || !new RegExp(`\\b${encoder}\\b`).test(listed.stdout),
|
|
)
|
|
if (missing.length > 0) return { ok: false, bin, reason: `sem encoder ${missing.join(', ')}` }
|
|
}
|
|
|
|
if (filters.length > 0) {
|
|
const listed = await run(bin, ['-hide_banner', '-filters'])
|
|
if (!listed.ok) return { ok: false, bin, reason: 'não listou filtros' }
|
|
const missing = filters.filter((filter) => !new RegExp(`\\b${filter}\\b`).test(listed.stdout))
|
|
if (missing.length > 0)
|
|
return { ok: false, bin, reason: `sem filtro(s): ${missing.join(', ')}` }
|
|
}
|
|
|
|
return { ok: true, bin, version: versionLine(version.stdout) }
|
|
}
|
|
|
|
const FFMPEG_INSTALL_HINT =
|
|
'Defina FFMPEG_PATH, instale o ffmpeg (ex.: `sudo apt install ffmpeg`) ou rode `pnpm install` para o binário empacotado.'
|
|
|
|
/**
|
|
* Resolves the first candidate that passes the probe. Explicit `FFMPEG_PATH` is strict.
|
|
*
|
|
* @param {{ env?: Record<string, string | undefined>, run?: RunFn, installer?: { path?: string } | null, audio?: boolean, encoders?: string[], filters?: string[], requirementsLabel?: string, installHint?: string }} [options]
|
|
* @returns {Promise<{ ok: true, bin: string, version: string, source: string }>}
|
|
*/
|
|
export const resolveFfmpeg = async ({
|
|
env = process.env,
|
|
run = defaultRun,
|
|
installer,
|
|
audio = false,
|
|
encoders = [],
|
|
filters = [],
|
|
requirementsLabel = '',
|
|
installHint = FFMPEG_INSTALL_HINT,
|
|
} = {}) => {
|
|
let packaged = installer
|
|
if (packaged === undefined) {
|
|
try {
|
|
packaged = (await import('@ffmpeg-installer/ffmpeg')).default
|
|
} catch {
|
|
// The packaged binary throws on import when its platform package is
|
|
// missing; the probe below decides, never the import.
|
|
packaged = null
|
|
}
|
|
}
|
|
const candidates = []
|
|
const explicit = env.FFMPEG_PATH?.trim()
|
|
if (explicit) candidates.push({ bin: explicit, source: 'FFMPEG_PATH', strict: true })
|
|
candidates.push({ bin: 'ffmpeg', source: 'PATH', strict: false })
|
|
if (packaged?.path) candidates.push({ bin: packaged.path, source: 'empacotado', strict: false })
|
|
|
|
const failures = []
|
|
for (const candidate of candidates) {
|
|
const probe = await probeFfmpeg(candidate.bin, { run, audio, encoders, filters })
|
|
if (probe.ok) {
|
|
return { ok: true, bin: probe.bin, version: probe.version, source: candidate.source }
|
|
}
|
|
failures.push(`${candidate.source}: ${probe.reason}`)
|
|
if (candidate.strict) break
|
|
}
|
|
|
|
const suffix = requirementsLabel ? ` (${requirementsLabel})` : ''
|
|
throw new Error(`Nenhum ffmpeg utilizável${suffix}. ${failures.join('; ')}. ${installHint}`)
|
|
}
|
|
|
|
/**
|
|
* Runs a process and fails with the stderr tail — never swallows the exit
|
|
* code. Shared by the CLI tools (ffmpeg and edge-tts).
|
|
*
|
|
* @param {string} bin
|
|
* @param {string[]} args
|
|
* @param {{ run?: RunFn, label?: string }} [options]
|
|
*/
|
|
export const runTool = async (bin, args, { run = defaultRun, label = 'processo' } = {}) => {
|
|
const result = await run(bin, args)
|
|
if (!result.ok) {
|
|
const tail = (result.stderr || result.error?.message || '').trim().slice(-400)
|
|
throw new Error(`${label} falhou${tail ? `: ${tail}` : ''}`)
|
|
}
|
|
return result
|
|
}
|