Stream video processing with Node.js and FFmpeg
Node.js and FFmpeg are powerful tools individually, but when combined, they offer a robust solution for real-time video processing. In this DevTip, we'll explore how to leverage Node.js streams and FFmpeg to build a lightweight, high-performance video transcoding API.
Why integrate FFmpeg with Node.js?
FFmpeg is a versatile, open-source multimedia framework capable of handling video transcoding, thumbnail extraction, watermarking, and much more. Node.js, with its non-blocking I/O model and efficient streaming capabilities, complements FFmpeg perfectly. This combination enables efficient real-time video processing without blocking the Node.js event loop, making it ideal for web applications and APIs.
Prerequisites: installing Node.js and FFmpeg
Ensure you have Node.js and FFmpeg installed on your system.
Node.js installation
We recommend using Node Version Manager (nvm) to manage Node.js versions.
# 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
FFmpeg installation
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
Verify the installations:
node -v
ffmpeg -version
Leveraging Node.js streams and child processes
Node.js streams allow efficient handling of data chunks, which is ideal for processing large video
files without loading the entire file into memory. Using the child_process module, we can invoke
the FFmpeg command-line tool directly from our Node.js application.
Save this example as video-tools.cjs. It shares one subprocess runner and one temporary-output
helper with the following examples, so failure cleanup cannot delete an existing destination.
Use real media fixtures; a text file named .mov is not a valid video. The H.264 examples expect
even frame dimensions for yuv420p output:
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
})
}
The function maps the first video stream and optional first audio stream to H.264 and AAC. FFmpeg writes into a new temporary directory on the destination filesystem; the helper publishes a completed, nonempty result through an exclusive hard link without copying it or overwriting an existing file. It removes only that temporary directory on success or failure.
Building a simple video transcoding API
Let's create a basic Express.js API endpoint that accepts video uploads and transcodes them using
the function we defined. Install the upload dependencies with npm install express@5 multer@2.
This is a local demonstration: keep its directories outside the webroot and add authentication,
request limits, and a bounded processing queue before exposing it publicly. Run FFmpeg with a
restricted OS account with resource limits, no network access, and no access to application
secrets when processing untrusted media.
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 limits upload size and generates a server-owned temporary name. Client MIME types and extensions are not trusted as proof of content. FFmpeg must successfully decode the selected streams before the API serves the result. This is still not a malware or file-safety verdict.
Extracting thumbnails and applying watermarks
FFmpeg can perform many other tasks. Here are functions for extracting a thumbnail and applying a watermark, including error handling and file checks.
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
})
Both functions reuse the output helper. Input seeking before -i avoids decoding from the start
for every thumbnail. A timestamp beyond the video’s end can produce no file even when FFmpeg exits
successfully; the helper rejects that outcome. Watermarking explicitly maps the filtered video and
optional source audio, and re-encodes audio for MP4 compatibility.
Advanced use case: processing large video files efficiently
For very large video files, Node.js streams allow piping data directly between sources, the FFmpeg
process, and destinations, avoiding high memory usage. This example demonstrates piping an input
file stream to FFmpeg and piping FFmpeg's output stream to a file. A pipe cannot seek: use a
stream-readable input such as the Matroska file shown here. Some MP4/MOV layouts require input
seeking; for those, pass the local filename to transcodeVideo instead of piping it.
Ordinary MP4 with +faststart requires seekable output. This example instead writes fragmented
MP4 using +frag_keyframe+empty_moov, as described in the
FFmpeg MOV/MP4 muxer documentation.
Confirm that your target player accepts fragmented MP4, or use the file-output example above.
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
})
This streaming approach significantly reduces memory usage. It uses pipe:0 and pipe:1,
configures stdio, and includes comprehensive error handling for the input stream, output stream,
and the FFmpeg process itself, ensuring resources are cleaned up and the process terminates
correctly even if errors occur mid-stream. Cancellation on SIGINT or SIGTERM terminates the child
process and removes its partial output.
Conclusion
Combining Node.js's asynchronous nature and streaming capabilities with FFmpeg's powerful multimedia
processing features allows you to build efficient and scalable video manipulation workflows. Whether
you need simple transcoding, thumbnail generation, watermarking, or complex stream processing, this
integration provides a flexible foundation. Remember to handle errors gracefully, manage child
processes correctly, validate inputs, and clean up temporary files. For performance-critical tasks,
investigate FFmpeg's hardware acceleration options (e.g., -hwaccel auto or specific options like
h264_videotoolbox on macOS).
If you need a managed service for complex media processing pipelines without handling FFmpeg infrastructure, consider exploring Transloadit.
