Procesamiento de video en streaming con Node.js y FFmpeg
Node.js y FFmpeg son herramientas potentes por separado, pero, al combinarse, ofrecen una solución robusta para el procesamiento de video en tiempo real. En este DevTip exploraremos cómo aprovechar los streams de Node.js y FFmpeg para crear una API de transcodificación de video ligera y de alto rendimiento.
¿Por qué integrar FFmpeg con Node.js?
FFmpeg es un framework multimedia versátil y de código abierto, capaz de encargarse de la transcodificación de video, la extracción de miniaturas, las marcas de agua y mucho más. Node.js, con su modelo de E/S no bloqueante y sus eficientes capacidades de streaming, complementa a FFmpeg a la perfección. Esta combinación permite un procesamiento de video en tiempo real eficiente sin bloquear el bucle de eventos de Node.js, lo que la hace ideal para aplicaciones web y API.
Requisitos previos: instalar Node.js y FFmpeg
Asegúrate de tener Node.js y FFmpeg instalados en tu sistema.
Instalación de Node.js
Recomendamos usar Node Version Manager (nvm) para gestionar las versiones de Node.js.
# Install nvm (node version manager)
curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
# Reload shell configuration (e.g., ~/.bashrc, ~/.zshrc) or restart your terminal
# Example for bash:
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
# Install the latest lts Node.js version
nvm install --lts
Instalación de FFmpeg
macOS:
# Install homebrew package manager if you don't have it
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
# Install FFmpeg
brew install ffmpeg
Ubuntu/Debian:
sudo apt update
sudo apt install ffmpeg -y
Verifica las instalaciones:
node -v
ffmpeg -version
Aprovechar los streams de Node.js y los procesos hijos
Los streams de Node.js permiten manejar fragmentos de datos de forma eficiente, lo que resulta ideal
para procesar archivos de video grandes sin cargar el archivo completo en memoria. Con el módulo
child_process podemos invocar la herramienta de línea de comandos de FFmpeg
directamente desde nuestra aplicación de Node.js.
Guarda este ejemplo como video-tools.cjs. Comparte un único ejecutor de subprocesos y
una única función auxiliar de salida temporal con los ejemplos siguientes, de modo que la limpieza
tras un fallo no pueda borrar un destino existente. Usa archivos de prueba con medios reales; un
archivo de texto llamado .mov no es un video válido. Los ejemplos con
H.264 esperan dimensiones de fotograma pares para la salida yuv420p:
const { spawn } = require('node:child_process')
const fs = require('node:fs')
const path = require('node:path')
async function withOutputFile(outputPath, produce) {
const destination = path.resolve(outputPath)
await fs.promises.mkdir(path.dirname(destination), { recursive: true })
const directory = await fs.promises.mkdtemp(path.join(path.dirname(destination), '.ffmpeg-'))
const temporaryPath = path.join(directory, `output${path.extname(destination)}`)
try {
await produce(temporaryPath)
const info = await fs.promises.stat(temporaryPath)
if (info.size === 0) throw new Error('FFmpeg produced an empty file')
// Both paths share a filesystem; an exclusive hard link publishes without copying the video.
await fs.promises.link(temporaryPath, destination)
return destination
} finally {
await fs.promises.rm(directory, { recursive: true, force: true })
}
}
function runFFmpeg(args) {
return new Promise((resolve, reject) => {
const child = spawn('ffmpeg', ['-nostdin', '-n', '-v', 'error', ...args], {
stdio: ['ignore', 'ignore', 'pipe'],
})
let diagnostics = ''
child.stderr.on('data', (chunk) => {
diagnostics = (diagnostics + chunk.toString()).slice(-8000)
})
child.on('error', reject)
child.on('close', (code) => {
if (code === 0) resolve()
else reject(new Error(`FFmpeg failed (${code}): ${diagnostics}`))
})
})
}
async function transcodeVideo(inputPath, outputPath) {
return withOutputFile(outputPath, (temporaryPath) =>
runFFmpeg([
'-i',
path.resolve(inputPath),
'-map',
'0:v:0',
'-map',
'0:a:0?',
'-c:v',
'libx264',
'-preset',
'medium',
'-crf',
'23',
'-pix_fmt',
'yuv420p',
'-c:a',
'aac',
'-b:a',
'128k',
'-movflags',
'+faststart',
temporaryPath,
]),
)
}
module.exports = { runFFmpeg, withOutputFile, transcodeVideo }
if (require.main === module) {
transcodeVideo('input.mov', path.join('processed', 'output.mp4'))
.then((output) => console.log(`Transcoding completed: ${output}`))
.catch((error) => {
console.error(error.message)
process.exitCode = 1
})
}
La función mapea el primer stream de video y el primer stream de audio opcional a H.264 y AAC. FFmpeg escribe en un directorio temporal nuevo en el sistema de archivos de destino; la función auxiliar publica un resultado completo y no vacío mediante un enlace duro exclusivo, sin copiarlo ni sobrescribir un archivo existente. Solo elimina ese directorio temporal, tanto si la operación tiene éxito como si falla.
Crear una API sencilla de transcodificación de video
Vamos a crear un endpoint básico de API con Express.js que acepte subidas de video y las
transcodifique con la función que definimos. Instala las dependencias de subida con
npm install express@5 multer@2. Esta es una demostración local: mantén sus directorios fuera del
webroot y añade autenticación, límites de peticiones y una cola de procesamiento acotada antes de
exponerla públicamente. Ejecuta FFmpeg con una cuenta del sistema operativo restringida, con límites
de recursos, sin acceso a la red y sin acceso a los secretos de la aplicación cuando proceses medios
que no sean de confianza.
const express = require('express')
const multer = require('multer')
const { mkdir, rm } = require('node:fs/promises')
const { randomUUID } = require('node:crypto')
const path = require('node:path')
const { transcodeVideo } = require('./video-tools.cjs')
const UPLOAD_DIR = path.resolve('uploads')
const TRANSCODED_DIR = path.resolve('transcoded')
const upload = multer({
dest: UPLOAD_DIR,
limits: { fileSize: 200 * 1024 * 1024, files: 1 },
})
const app = express()
function diagnosticCode(error) {
// Never log arbitrary messages, paths, or third-party error payloads.
return ['ENOENT', 'EACCES', 'EEXIST', 'LIMIT_FILE_SIZE'].includes(error?.code)
? error.code
: 'PROCESSING_FAILED'
}
function logCleanupFailure(error) {
console.error('Temporary file cleanup failed', { code: diagnosticCode(error) })
}
app.post('/transcode', upload.single('video'), async (req, res, next) => {
if (!req.file) return res.status(400).json({ error: 'No video file uploaded.' })
const inputPath = req.file.path
const outputFilename = `${randomUUID()}.mp4`
const outputPath = path.join(TRANSCODED_DIR, outputFilename)
try {
await transcodeVideo(inputPath, outputPath)
res.download(outputPath, outputFilename, (error) => {
// Download completion is the point at which the output can be removed.
Promise.all([rm(inputPath, { force: true }), rm(outputPath, { force: true })]).catch(
logCleanupFailure,
)
if (error) next(error)
})
} catch (error) {
await rm(inputPath, { force: true }).catch(logCleanupFailure)
next(error)
}
})
app.use((error, req, res, next) => {
if (res.headersSent) return next(error)
const tooLarge = error instanceof multer.MulterError && error.code === 'LIMIT_FILE_SIZE'
console.error('Upload or transcoding failed', { code: diagnosticCode(error) })
res.status(tooLarge ? 413 : 422).json({
error: tooLarge ? 'Video exceeds the upload size limit.' : 'Video could not be processed.',
})
})
async function main() {
await mkdir(UPLOAD_DIR, { recursive: true, mode: 0o700 })
await mkdir(TRANSCODED_DIR, { recursive: true, mode: 0o700 })
const port = process.env.PORT || 3000
app.listen(port, () => console.log(`Server running on http://localhost:${port}`))
}
main().catch((error) => {
console.error('Server startup failed', { code: diagnosticCode(error) })
process.exitCode = 1
})
Multer limita el tamaño de la subida y genera un nombre temporal que pertenece al servidor. Los tipos MIME y las extensiones que envía el cliente no se consideran prueba del contenido. FFmpeg debe decodificar correctamente los streams seleccionados antes de que la API entregue el resultado. Aun así, esto sigue sin ser un veredicto sobre malware ni sobre la seguridad del archivo.
Extraer miniaturas y aplicar marcas de agua
FFmpeg puede realizar muchas otras tareas. Aquí tienes funciones para extraer una miniatura y aplicar una marca de agua, con manejo de errores y comprobaciones de archivos.
const path = require('node:path')
const { runFFmpeg, withOutputFile } = require('./video-tools.cjs')
async function extractThumbnail(inputPath, outputPath, timestamp = '00:00:01.000') {
return withOutputFile(outputPath, (temporaryPath) =>
runFFmpeg([
'-ss',
timestamp,
'-i',
path.resolve(inputPath),
'-map',
'0:v:0',
'-frames:v',
'1',
'-q:v',
'2',
'-f',
'image2',
temporaryPath,
]),
)
}
async function applyWatermark(inputPath, watermarkPath, outputPath) {
return withOutputFile(outputPath, (temporaryPath) =>
runFFmpeg([
'-i',
path.resolve(inputPath),
'-i',
path.resolve(watermarkPath),
'-filter_complex',
'[0:v:0][1:v:0]overlay=10:10[v]',
'-map',
'[v]',
'-map',
'0:a:0?',
'-c:v',
'libx264',
'-crf',
'23',
'-pix_fmt',
'yuv420p',
'-c:a',
'aac',
'-b:a',
'128k',
'-movflags',
'+faststart',
temporaryPath,
]),
)
}
// These examples require a real input video and PNG watermark.
async function main() {
await extractThumbnail('input.mp4', path.join('processed', 'thumbnail.jpg'))
await applyWatermark('input.mp4', 'watermark.png', path.join('processed', 'watermarked.mp4'))
}
main().catch((error) => {
console.error(error.message)
process.exitCode = 1
})
Ambas funciones reutilizan la función auxiliar de salida. Buscar en la entrada antes de
-i evita decodificar desde el inicio en cada miniatura. Una marca de
tiempo posterior al final del video puede no producir ningún archivo aunque FFmpeg termine
correctamente; la función auxiliar rechaza ese resultado. La marca de agua mapea explícitamente el
video filtrado y el audio de origen opcional, y vuelve a codificar el audio para que sea compatible
con MP4.
Caso de uso avanzado: procesar archivos de video grandes de forma eficiente
Para archivos de video muy grandes, los streams de Node.js permiten conectar los datos mediante
pipes directamente entre las fuentes, el proceso de FFmpeg y los destinos, evitando un uso elevado
de memoria. Este ejemplo muestra cómo enviar por un pipe un stream de archivo de entrada a FFmpeg y
enviar el stream de salida de FFmpeg a un archivo. Un pipe no permite búsqueda posicional: usa una
entrada legible como stream, como el archivo Matroska que se muestra aquí. Algunas disposiciones de
MP4/MOV requieren búsqueda en la entrada; en esos casos, pasa el nombre del archivo local a
transcodeVideo en lugar de enviarlo por un pipe.
El MP4 normal con +faststart requiere una salida en la que se pueda buscar. En su
lugar, este ejemplo escribe MP4 fragmentado con +frag_keyframe+empty_moov, tal como se describe
en la documentación del muxer MOV/MP4 de FFmpeg.
Confirma que tu reproductor de destino acepta MP4 fragmentado o usa el ejemplo de salida a archivo
anterior.
const { spawn } = require('node:child_process')
const fs = require('node:fs')
const path = require('node:path')
const { pipeline } = require('node:stream/promises')
const { withOutputFile } = require('./video-tools.cjs')
async function streamTranscode(inputPath, outputPath) {
return withOutputFile(outputPath, async (temporaryPath) => {
const abortController = new AbortController()
const child = spawn(
'ffmpeg',
[
'-nostdin',
'-v',
'error',
'-i',
'pipe:0',
'-map',
'0:v:0',
'-map',
'0:a:0?',
'-c:v',
'libx264',
'-preset',
'medium',
'-crf',
'23',
'-pix_fmt',
'yuv420p',
'-c:a',
'aac',
'-b:a',
'128k',
'-movflags',
'+frag_keyframe+empty_moov',
'-f',
'mp4',
'pipe:1',
],
{ stdio: ['pipe', 'pipe', 'pipe'] },
)
let diagnostics = ''
child.stderr.on('data', (chunk) => {
diagnostics = (diagnostics + chunk.toString()).slice(-8000)
})
const exited = new Promise((resolve, reject) => {
child.on('error', reject)
child.on('close', (code) => {
if (code === 0) resolve()
else reject(new Error(`FFmpeg failed (${code}): ${diagnostics}`))
})
})
const options = { signal: abortController.signal }
const inputDone = pipeline(fs.createReadStream(inputPath), child.stdin, options)
const outputDone = pipeline(
child.stdout,
fs.createWriteStream(temporaryPath, { flags: 'wx' }),
options,
)
const stop = () => {
abortController.abort()
if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL')
}
process.once('SIGINT', stop)
process.once('SIGTERM', stop)
try {
// Success requires both pipelines to finish as well as a zero process exit.
await Promise.all([exited, inputDone, outputDone])
} catch (error) {
stop()
await Promise.allSettled([exited, inputDone, outputDone])
throw error
} finally {
process.off('SIGINT', stop)
process.off('SIGTERM', stop)
}
})
}
streamTranscode('large_input.mkv', path.join('processed', 'large_output.mp4'))
.then((output) => console.log(`Streaming transcoding completed: ${output}`))
.catch((error) => {
console.error(error.message)
process.exitCode = 1
})
Este enfoque de streaming reduce significativamente el uso de memoria. Usa
pipe:0 y pipe:1, configura
stdio e incluye un manejo de errores exhaustivo para el stream de entrada,
el stream de salida y el propio proceso de FFmpeg, lo que garantiza que los recursos se liberen y
que el proceso termine correctamente incluso si se producen errores a mitad del stream. La
cancelación con SIGINT o SIGTERM termina el proceso hijo y elimina su salida parcial.
Conclusión
Combinar la naturaleza asíncrona y las capacidades de streaming de Node.js con las potentes
funciones de procesamiento multimedia de FFmpeg te permite crear flujos de trabajo de manipulación
de video eficientes y escalables. Tanto si necesitas una transcodificación sencilla como generación
de miniaturas, marcas de agua o procesamiento complejo de streams, esta integración ofrece una base
flexible. Recuerda manejar los errores con elegancia, gestionar correctamente los procesos hijos,
validar las entradas y limpiar los archivos temporales. Para tareas críticas en cuanto al
rendimiento, investiga las opciones de aceleración por hardware de FFmpeg (por ejemplo,
-hwaccel auto u opciones específicas como h264_videotoolbox en macOS).
Si necesitas un servicio gestionado para pipelines complejos de procesamiento de medios sin ocuparte de la infraestructura de FFmpeg, considera explorar Transloadit.
