Transmite la salida de FFmpeg con Node.js
Usa Node.js para iniciar FFmpeg, transmitir los bytes codificados a un archivo temporal y publicar el resultado solo cuando hayan terminado tanto el encoding como la escritura. Esta guía te proporciona un comando local que convierte un video a H.264 con audio AAC opcional, rechaza la sobrescritura de una salida existente y elimina los resultados parciales si se produce un error o una cancelación.
Elige qué transmitir
La entrada es un archivo local completo. FFmpeg lo abre directamente para poder desplazarse por él,
lo que importa para los archivos MP4/MOV cuyos metadatos están al final. Su salida pasa por
stdout hacia un flujo de escritura de Node.js. Conservas la entrada original y
un archivo de salida completo; no interviene ningún servidor de subidas.
Una tubería no permite desplazarse por los datos. Para la salida MP4, usa
+frag_keyframe+empty_moov para escribir un encabezado inicial y los fragmentos posteriores.
En cambio, un MP4 convencional con +faststart necesita un archivo de salida que
permita desplazarse por él.
La documentación del multiplexor MP4 de FFmpeg explica la
fragmentación y su contrapartida en compatibilidad: algunos reproductores y editores no aceptan MP4
fragmentado. Comprueba la compatibilidad del programa que usará la salida antes de elegir este formato.
Aquí, streaming describe cómo se mueven los bytes. El encoding se ejecuta tan rápido como lo permite la máquina, y el nombre de archivo final aparece solo al terminar. Esta es una conversión por lotes, sin garantía de velocidad de encoding en tiempo real, reproducción en vivo ni streaming adaptativo.
Prepara un ejemplo local
Usa Linux, Node.js 24 o 26 y una compilación de FFmpeg con los codificadores
libx264 y aac, además de ffprobe.
El ejemplo usa el soporte integrado de TypeScript de Node
y no necesita paquetes npm. Instala las herramientas que falten desde las páginas de
descargas de Node.js y
descargas de FFmpeg. Los siguientes comandos usan Bash y un sistema
de archivos local que admite enlaces duros. Empieza con un video de confianza; este comando no es
un entorno aislado para subidas.
Comprueba tus herramientas:
node --version &&
ffmpeg -version &&
ffprobe -version &&
ffmpeg -hide_banner -encoders
Confirma que la lista de codificadores incluye libx264 y
aac. Las combinaciones probadas son Node.js 26.8.1 con FFmpeg 9.0.1 en Linux,
y Node.js 24.13.0 y 26.5.0 con FFmpeg 6.1.1 en Ubuntu 24.04.
Crea un directorio nuevo y una muestra de tres segundos con video y un tono. Conserva la cadena
&&: si el directorio ya existe o falla el cambio de directorio, no se
escribe nada dentro de él. La opción -n también impide reemplazar la muestra.
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
Permanece en ese directorio para los comandos restantes. La muestra usa deliberadamente un archivo
MP4 normal; pasar su nombre de archivo a FFmpeg evita las restricciones de desplazamiento que
supone suministrarlo a través de pipe:0.
Conecta FFmpeg a un flujo de escritura
Guarda el siguiente script completo como transcode.ts. El directorio de salida se
crea si es necesario. Se conserva cualquier destino existente, incluso si otro proceso lo crea
mientras el encoding está en curso.
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() recibe un array de argumentos y no ejecuta un intérprete de comandos.
pipeline() aplica contrapresión: cuando el flujo de escritura está ocupado, Node
deja de leer la salida de FFmpeg y, con el tiempo, la tubería obliga a FFmpeg a esperar. También
propaga los errores de los flujos. Consulta la
documentación de pipelines de flujos de Node.
El primer flujo de video es obligatorio. El ? de
0:a:0? hace que el primer flujo de audio sea opcional, como especifican las
reglas de asignación de flujos de FFmpeg. El relleno añade una fila o
columna cuando es necesario para que la salida yuv420p de H.264 tenga
dimensiones pares. Se omiten los subtítulos, las pistas de audio adicionales y los demás flujos de video.
Para que el proceso tenga éxito, deben producirse tanto el
evento close del proceso hijo con código de
salida cero como la finalización del pipeline de escritura. Un error de escritura detiene FFmpeg;
si el proceso de FFmpeg falla, la escritura se aborta. El código espera a que ambos terminen antes
de eliminar los resultados temporales. Después, un enlace duro exclusivo publica el archivo
completo sin realizar una segunda copia íntegra. Esto requiere soporte para enlaces duros y no
garantiza la persistencia ante una pérdida de energía.
Ejecuta e inspecciona la conversión
Ejecuta la muestra y luego inspecciona los flujos que contiene realmente:
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
Deberías recibir un mensaje de finalización que contenga la ruta absoluta de salida. El análisis
debería indicar video H.264 a 320 × 180, audio AAC y una duración de alrededor de tres segundos.
Un video sin audio produce solo el flujo de video. Para tu propia entrada, usa rutas entre comillas
y un nombre de salida nuevo, por ejemplo, node transcode.ts -- "my clip.mov" "my clip encoded.mp4".
Ejecuta de nuevo el mismo comando para comprobar la política de sobrescritura: termina con el
código 1 e informa de EEXIST. La salida original permanece sin cambios.
El rechazo ocurre al publicar, por lo que la segunda ejecución también dedica tiempo al encoding.
Elige otro nombre de archivo para conservar otro resultado.
Para obtener un MP4 convencional que pueda abrir un editor, vuelve a multiplexar la salida completa en otro archivo nuevo:
ffmpeg -nostdin -n -v error -i output.mp4 -map 0 -c copy -movflags +faststart compatible.mp4
Esto copia los flujos codificados sin volver a realizar el encoding. Escribe directamente en
compatible.mp4, por lo que una remultiplexación interrumpida puede dejar allí un
archivo parcial. -n rechaza un destino existente; inspecciona o elimina
ese archivo parcial por tu cuenta antes de volver a intentarlo. Algunas versiones de FFmpeg
devuelven el estado cero cuando rechazan una sobrescritura, así que revisa el diagnóstico y el
archivo en lugar de confiar solo en el estado. Este archivo adicional también necesita espacio en disco.
Gestiona los errores y los límites de recursos
Ctrl+C, SIGTERM o el plazo predeterminado de 120 segundos abortan el procesamiento y eliminan la
salida temporal. Aumenta el plazo para una entrada de confianza más grande con
--timeout 600. Una vez iniciada la publicación, puede permanecer un destino
completo; la cancelación nunca lo elimina. SIGKILL, un fallo de la máquina o un error de permisos
durante la limpieza pueden dejar un directorio .ffmpeg-* que habrá que
inspeccionar manualmente.
La línea de error predeterminada contiene solo un código verificado. Añade
--verbose para la depuración local; los errores de FFmpeg incluyen como máximo
los últimos 8.000 caracteres de diagnóstico. No reenvíes esa salida detallada a un cliente HTTP.
| Resultado | Qué revisar |
|---|---|
ENOENT | No se pudo encontrar el ejecutable de FFmpeg. Usa --ffmpeg /absolute/path/to/ffmpeg si es necesario. |
PROCESSING_FAILED | Usa --verbose para inspeccionar una entrada no válida o ausente, un codificador no disponible o contenido multimedia no compatible. |
EEXIST | Elige un nombre de archivo de salida nuevo. |
EACCES o ENOSPC | Comprueba los permisos del directorio y el espacio disponible en disco. |
ABORT_ERR | La tarea se canceló o excedió su plazo. |
-xerror hace que FFmpeg se detenga ante los
errores notificados, pero una conversión exitosa no demuestra que el origen esté completo o sea
seguro. Algunos contenedores truncados aún contienen fotogramas decodificables. Si tu aplicación
conoce una duración o una suma de verificación esperadas, valídalas por separado.
Este comando ejecuta una conversión, limita los hilos solicitados del códec, acota los diagnósticos conservados y evita almacenar en un búfer todo el video en Node. FFmpeg sigue necesitando memoria para su propio decodificador y codificador; la contrapresión no es una cuota de memoria ni de CPU. Tampoco se limita el tamaño de salida. Para una API de subidas, almacena primero la entrada de forma temporal, aplica límites de subida y de disco, y ejecuta las tareas a través de una cola acotada con límites de recursos del sistema operativo. El pipeline local de flujos es el paso de procesamiento, no un servicio público completo.
