Faça streaming da saída do FFmpeg com Node.js
Use o Node.js para iniciar o FFmpeg, transmitir os bytes codificados para um arquivo temporário e publicar o resultado somente quando a codificação e a gravação tiverem terminado. Este passo a passo oferece um comando local que converte um vídeo para H.264 com áudio AAC opcional, se recusa a sobrescrever uma saída existente e limpa o trabalho parcial em caso de falha ou cancelamento.
Escolha o que processar em streaming
A entrada é um arquivo local completo. O FFmpeg o abre diretamente para poder reposicionar a leitura
(seek), o que importa para arquivos MP4/MOV cujos metadados ficam no final. A saída passa por
stdout até um stream gravável do Node.js. Você mantém a entrada original e um
arquivo de saída concluído; nenhum servidor de upload está envolvido.
Um pipe não permite reposicionamento. Para saída MP4, use +frag_keyframe+empty_moov para gravar um
cabeçalho inicial e os fragmentos seguintes. Um MP4 comum com +faststart precisa,
em vez disso, de um arquivo de saída que permita reposicionamento.
A documentação do muxer MP4 do FFmpeg explica a fragmentação e seu
custo em compatibilidade: alguns players e editores não aceitam MP4 fragmentado. Verifique o
consumidor pretendido antes de escolher este formato de saída.
Aqui, streaming descreve como os bytes se movem. A codificação roda tão rápido quanto a máquina permite, e o nome final do arquivo só aparece após a conclusão. Trata-se de uma conversão em lote, sem promessa de velocidade de codificação em tempo real, reprodução ao vivo ou streaming adaptativo.
Prepare um exemplo local
Use Linux, Node.js 24 ou 26 e um build do FFmpeg com os encoders libx264 e
aac, além do ffprobe. O exemplo usa o
suporte nativo a TypeScript do Node e não precisa de pacotes npm.
Instale as ferramentas que faltarem pelas páginas de downloads do Node.js
e downloads do FFmpeg. Os comandos abaixo usam Bash e um sistema de
arquivos local com suporte a hard links. Comece com um vídeo confiável; este comando não é um
sandbox para uploads.
Verifique suas ferramentas:
node --version &&
ffmpeg -version &&
ffprobe -version &&
ffmpeg -hide_banner -encoders
Confirme que a lista de encoders inclui libx264 e aac.
As combinações testadas são Node.js 26.8.1 com FFmpeg 9.0.1 no Linux, e Node.js 24.13.0 e 26.5.0
com FFmpeg 6.1.1 no Ubuntu 24.04.
Crie um novo diretório e uma amostra de três segundos com vídeo e um tom. Mantenha a cadeia
&&: se o diretório já existir ou a navegação falhar, nada é gravado dentro
dele. A flag -n também se recusa a substituir a amostra.
mkdir node-ffmpeg-demo &&
cd node-ffmpeg-demo &&
printf '{"type":"module"}\n' > package.json &&
ffmpeg -nostdin -n -v error \
-f lavfi -i testsrc2=size=320x180:rate=24 \
-f lavfi -i sine=frequency=440:sample_rate=48000 \
-t 3 -c:v libx264 -threads 2 -pix_fmt yuv420p -c:a aac input.mp4
Permaneça nesse diretório para os comandos restantes. A amostra usa deliberadamente um arquivo MP4
normal; passar o nome do arquivo ao FFmpeg evita as restrições de reposicionamento que surgiriam ao
alimentá-lo por pipe:0.
Conecte o FFmpeg a um stream gravável
Salve o script completo abaixo como transcode.ts. O diretório de saída é criado se
necessário. Um destino existente é preservado, mesmo que outro processo o crie enquanto a
codificação está em andamento.
import { spawn } from 'node:child_process'
import { createWriteStream } from 'node:fs'
import { link, mkdir, mkdtemp, rm, stat } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { pipeline } from 'node:stream/promises'
import { parseArgs } from 'node:util'
function diagnosticCode(error: unknown): string {
if (typeof error === 'object' && error !== null && 'code' in error) {
const code = error.code
if (
typeof code === 'string' &&
['ENOENT', 'EACCES', 'EEXIST', 'ENOSPC', 'ABORT_ERR'].includes(code)
) return code
}
return 'PROCESSING_FAILED'
}
function logCleanupFailure(error: unknown): void {
console.error('Temporary file cleanup failed', { code: diagnosticCode(error) })
}
async function withOutputFile(
outputPath: string,
signal: AbortSignal,
produce: (temporary: string) => Promise<void>,
): Promise<string> {
signal.throwIfAborted()
const destination = resolve(outputPath)
await mkdir(dirname(destination), { recursive: true })
const directory = await mkdtemp(join(dirname(destination), '.ffmpeg-'))
const temporary = join(directory, 'output.mp4')
try {
signal.throwIfAborted()
await produce(temporary)
const info = await stat(temporary)
if (info.size === 0) throw new Error('FFmpeg produced an empty file')
signal.throwIfAborted()
// An exclusive hard link publishes on the same filesystem without copying or replacing data.
await link(temporary, destination)
return destination
} finally {
// A cleanup warning must not replace the original processing error or undo a published file.
await rm(directory, { recursive: true, force: true }).catch(logCleanupFailure)
}
}
async function streamTranscode(
inputPath: string,
outputPath: string,
signal: AbortSignal,
executable: string,
): Promise<string> {
return withOutputFile(outputPath, signal, async (temporary) => {
signal.throwIfAborted()
const child = spawn(executable, [
'-nostdin', '-v', 'error', '-xerror',
'-threads', '2', '-i', resolve(inputPath),
'-map', '0:v:0', '-map', '0:a:0?',
'-filter_threads', '1', '-vf', 'pad=ceil(iw/2)*2:ceil(ih/2)*2',
'-c:v', 'libx264', '-threads', '2', '-preset', 'medium', '-crf', '23',
'-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '128k',
'-movflags', '+frag_keyframe+empty_moov', '-f', 'mp4', 'pipe:1',
], { stdio: ['ignore', 'pipe', 'pipe'] })
let diagnostics = ''
let processError: Error | undefined
child.stderr.setEncoding('utf8')
child.stderr.on('data', (chunk: string) => {
diagnostics = (diagnostics + chunk).slice(-8000)
})
const closed = new Promise<void>((resolveClosed, reject) => {
child.once('error', (error) => {
processError = error
})
child.once('close', (code, killedBy) => {
if (processError) reject(processError)
else if (code === 0) resolveClosed()
else reject(new Error(`FFmpeg failed (${killedBy ?? code}): ${diagnostics}`))
})
})
const io = new AbortController()
const written = pipeline(
child.stdout,
createWriteStream(temporary, { flags: 'wx' }),
{ signal: io.signal },
)
const stop = (): void => {
io.abort()
if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL')
}
signal.addEventListener('abort', stop, { once: true })
if (signal.aborted) stop()
try {
// Neither a clean stdout EOF nor FFmpeg exiting alone proves that the job succeeded.
await Promise.all([closed, written])
signal.throwIfAborted()
} catch (error) {
stop()
await Promise.allSettled([closed, written])
signal.throwIfAborted()
throw error
} finally {
signal.removeEventListener('abort', stop)
}
})
}
let verbose = false
async function main(): Promise<void> {
const { values, positionals } = parseArgs({
allowPositionals: true,
options: {
ffmpeg: { type: 'string', default: 'ffmpeg' },
timeout: { type: 'string', default: '120' },
verbose: { type: 'boolean', default: false },
},
})
verbose = values.verbose
const [input, output] = positionals
const seconds = Number(values.timeout)
if (
!input || !output || positionals.length !== 2 ||
!Number.isFinite(seconds) || seconds <= 0 || seconds > 86400
) {
throw new Error('Usage: node transcode.ts [--timeout seconds] [--verbose] -- input output.mp4')
}
const controller = new AbortController()
const stop = (): void => controller.abort(
Object.assign(new Error('Transcoding cancelled or timed out'), { code: 'ABORT_ERR' }),
)
process.once('SIGINT', stop)
process.once('SIGTERM', stop)
const timer = setTimeout(stop, seconds * 1000)
try {
const outputFile = await streamTranscode(input, output, controller.signal, values.ffmpeg)
console.log(`Transcoding completed: ${outputFile}`)
} finally {
clearTimeout(timer)
process.off('SIGINT', stop)
process.off('SIGTERM', stop)
}
}
main().catch((error: unknown) => {
console.error('Transcoding failed', { code: diagnosticCode(error) })
// Detailed diagnostics are opt-in because FFmpeg messages can include local paths and metadata.
if (verbose && error instanceof Error) console.error(error.message)
process.exitCode = 1
})
spawn() recebe um array de argumentos e não executa um shell.
pipeline() aplica backpressure: quando o stream gravável está ocupado, o Node para
de drenar a saída do FFmpeg, e o pipe acaba fazendo o FFmpeg esperar. Ele também propaga erros de
stream. Consulte a documentação de pipeline de streams do Node.
O primeiro stream de vídeo é obrigatório. O ? em
0:a:0? torna o primeiro stream de áudio opcional, conforme especificado pelas
regras de mapeamento de streams do FFmpeg. O padding adiciona uma
linha ou coluna quando necessário para que a saída yuv420p do H.264 tenha
dimensões pares. Legendas, faixas de áudio extras e outros streams de vídeo são omitidos.
O sucesso exige tanto o evento close do
processo filho com código de saída zero quanto a conclusão do pipeline de gravação. Uma falha de
gravação interrompe o FFmpeg; uma falha no processo do FFmpeg aborta a gravação. O código espera que
ambos terminem antes de remover o trabalho temporário. Em seguida, um hard link exclusivo publica o
arquivo concluído sem uma segunda cópia completa. Isso exige suporte a hard links e não oferece
durabilidade contra quedas de energia.
Execute e inspecione a conversão
Execute a amostra e depois inspecione os streams reais:
node transcode.ts -- input.mp4 output.mp4 &&
ffprobe -v error -show_entries stream=codec_name,codec_type,width,height \
-show_entries format=duration -of json output.mp4
Espere uma mensagem de conclusão com o caminho absoluto da saída. A saída do ffprobe deve informar
vídeo H.264 em 320 × 180, áudio AAC e duração de cerca de três segundos. Um vídeo sem áudio produz
apenas o stream de vídeo. Para sua própria entrada, use caminhos entre aspas e um novo nome de saída,
por exemplo node transcode.ts -- "my clip.mov" "my clip encoded.mp4".
Execute o mesmo comando novamente para verificar a política de sobrescrita: ele termina com código 1
e informa EEXIST. A saída original permanece inalterada. A recusa acontece na
publicação, então a segunda execução ainda gasta tempo codificando. Escolha outro nome de arquivo
para manter outro resultado.
Para obter um MP4 convencional que um editor consiga abrir, faça o remux da saída concluída para outro arquivo novo:
ffmpeg -nostdin -n -v error -i output.mp4 -map 0 -c copy -movflags +faststart compatible.mp4
Isso copia os streams codificados sem recodificação. O comando grava diretamente em
compatible.mp4, então um remux interrompido pode deixar um arquivo parcial ali.
-n recusa um destino existente; inspecione ou remova esse arquivo parcial
você mesmo antes de tentar novamente. Algumas versões do FFmpeg retornam status zero para uma recusa
de sobrescrita, então verifique a mensagem de diagnóstico e o arquivo em vez de confiar apenas no
status. Esse arquivo extra também precisa de espaço em disco.
Trate falhas e limites de recursos
Ctrl+C, SIGTERM ou o prazo padrão de 120 segundos abortam o processamento e removem a saída
temporária. Aumente o prazo para uma entrada confiável maior com --timeout 600. Depois
que a publicação começa, um destino concluído pode permanecer; o cancelamento nunca o exclui.
SIGKILL, uma falha da máquina ou uma falha de permissão na limpeza podem deixar um diretório
.ffmpeg-* para inspeção manual.
A linha de erro padrão contém apenas um código verificado. Adicione --verbose para
depuração local; erros do FFmpeg incluem no máximo os últimos 8.000 caracteres de diagnóstico. Não
repasse essa saída detalhada a um cliente HTTP.
| Resultado | O que verificar |
|---|---|
ENOENT | O executável do FFmpeg não foi encontrado. Use --ffmpeg /absolute/path/to/ffmpeg se necessário. |
PROCESSING_FAILED | Use --verbose para inspecionar uma entrada inválida ou ausente, um encoder indisponível ou uma mídia sem suporte. |
EEXIST | Escolha um novo nome de arquivo de saída. |
EACCES ou ENOSPC | Verifique as permissões do diretório e o espaço disponível em disco. |
ABORT_ERR | A tarefa foi cancelada ou excedeu seu prazo. |
-xerror faz o FFmpeg parar em erros reportados,
mas uma conversão bem-sucedida não prova que a origem esteja completa ou segura. Alguns contêineres
truncados ainda contêm frames decodificáveis. Se a sua aplicação conhece uma duração ou um checksum
esperado, valide isso separadamente.
Este comando executa uma conversão, limita as threads de codec solicitadas, restringe os diagnósticos retidos e evita manter o vídeo inteiro em buffer no Node. O FFmpeg ainda precisa de memória própria para o decoder e o encoder; backpressure não é uma cota de memória ou CPU. O tamanho da saída também não é limitado. Para uma API de upload, armazene a entrada primeiro, imponha limites de upload e de disco e execute as tarefas por meio de uma fila limitada com limites de recursos do sistema operacional. O pipeline local de streams é a etapa de processamento, não um serviço público completo.
