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.
Here's a basic example of transcoding a video file using child_process.spawn
with proper error
handling:
const { spawn } = require('node:child_process')
const fs = require('node:fs')
const path = require('node:path')
function transcodeVideo(inputPath, outputPath) {
return new Promise((resolve, reject) => {
// Check if input file exists
if (!fs.existsSync(inputPath)) {
return reject(new Error(`Input file not found: ${inputPath}`))
}
// Ensure output directory exists
const outputDir = path.dirname(outputPath)
if (!fs.existsSync(outputDir)) {
try {
fs.mkdirSync(outputDir, { recursive: true })
} catch (err) {
return reject(new Error(`Failed to create output directory: ${err.message}`))
}
}
const ffmpegArgs = [
'-i',
inputPath,
'-c:v',
'libx264', // Video codec: H.264
'-preset',
'medium', // Encoding speed/quality trade-off
'-crf',
'23', // Constant Rate Factor (lower value = higher quality, larger file)
'-profile:v',
'high', // H.264 profile
'-level:v',
'4.0', // H.264 level
'-c:a',
'aac', // Audio codec: AAC
'-b:a',
'128k', // Audio bitrate
'-movflags',
'+faststart', // Optimize for web streaming
outputPath,
]
const ffmpegProcess = spawn('ffmpeg', ffmpegArgs)
let stderrOutput = ''
ffmpegProcess.stderr.on('data', (data) => {
stderrOutput += data.toString()
// Optional: Log progress or detailed output
// console.log(`ffmpeg stderr: ${data}`);
})
ffmpegProcess.on('close', (code) => {
if (code === 0) {
console.log(`Transcoding finished successfully: ${outputPath}`)
resolve(outputPath)
} else {
console.error(`ffmpeg exited with code ${code}`)
console.error(`ffmpeg stderr:\\n${stderrOutput}`)
// Attempt to clean up potentially incomplete output file
fs.unlink(outputPath, (err) => {
if (err && err.code !== 'ENOENT') {
// Ignore if file doesn't exist
console.error(`Error deleting incomplete output file: ${err.message}`)
}
})
reject(new Error(`ffmpeg exited with code ${code}`))
}
})
ffmpegProcess.on('error', (err) => {
console.error('Failed to start ffmpeg process:', err)
reject(err)
})
})
}
// Example usage:
const inputVideo = 'input.mov' // Replace with your input video file
const outputVideo = path.join('processed', 'output.mp4') // Place output in a sub-directory
// Create a dummy input file for testing if it doesn't exist
if (!fs.existsSync(inputVideo)) {
console.log(`Creating dummy input file: ${inputVideo}`)
// You might need a real video file for actual transcoding
try {
fs.writeFileSync(inputVideo, 'dummy content')
} catch (err) {
console.error(`Failed to create dummy input file: ${err.message}`)
// Exit or handle error appropriately if dummy file creation fails
process.exit(1)
}
}
transcodeVideo(inputVideo, outputVideo)
.then((outputPath) => console.log(`Transcoding completed: ${outputPath}`))
.catch((err) => console.error('Transcoding failed:', err.message))
This function spawns FFmpeg as a child process, passes modern arguments for transcoding to H.264 video and AAC audio, and returns a Promise. It includes checks for input file existence, creates the output directory if needed, handles errors during process spawning and execution, logs FFmpeg's standard error output on failure, and attempts to clean up incomplete output files.
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. We'll use multer
for handling file uploads securely.
const express = require('express')
const multer = require('multer')
const { spawn } = require('node:child_process')
const fs = require('node:fs')
const path = require('node:path')
const crypto = require('node:crypto') // For secure filename generation
// --- Transcoding Function (adapted from previous example) ---
function transcodeVideoApi(inputPath, outputPath) {
return new Promise((resolve, reject) => {
// Input existence check is handled by multer/API logic before calling this
const outputDir = path.dirname(outputPath)
if (!fs.existsSync(outputDir)) {
try {
fs.mkdirSync(outputDir, { recursive: true })
} catch (err) {
return reject(new Error(`Failed to create output directory: ${err.message}`))
}
}
const ffmpegArgs = [
'-i',
inputPath,
'-c:v',
'libx264',
'-preset',
'medium',
'-profile:v',
'high',
'-level:v',
'4.0',
'-crf',
'23',
'-c:a',
'aac',
'-b:a',
'128k',
'-movflags',
'+faststart',
outputPath,
]
const ffmpegProcess = spawn('ffmpeg', ffmpegArgs)
let stderrOutput = ''
ffmpegProcess.stderr.on('data', (data) => {
stderrOutput += data.toString()
})
ffmpegProcess.on('close', (code) => {
if (code === 0) resolve(outputPath)
else {
console.error(`ffmpeg exited with code ${code}. Stderr: ${stderrOutput}`)
fs.unlink(outputPath, (err) => {
if (err && err.code !== 'ENOENT')
console.error(`Error deleting incomplete output file: ${err.message}`)
})
reject(new Error(`Transcoding failed with code ${code}`))
}
})
ffmpegProcess.on('error', (err) => {
console.error('FFmpeg process error:', err)
reject(err)
})
})
}
// --- End Transcoding Function ---
// Configure directories
const UPLOAD_DIR = 'uploads'
const TRANSCODED_DIR = 'transcoded'
// Ensure directories exist at startup
;[UPLOAD_DIR, TRANSCODED_DIR].forEach((dir) => {
if (!fs.existsSync(dir)) {
try {
fs.mkdirSync(dir, { recursive: true })
console.log(`Created directory: ${dir}`)
} catch (err) {
console.error(`Fatal: Could not create directory ${dir}. Exiting. Error: ${err.message}`)
process.exit(1)
}
}
})
// Configure secure file storage with multer
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, UPLOAD_DIR)
},
filename: (req, file, cb) => {
// Generate a secure, unique filename to prevent collisions and path traversal
const uniqueSuffix = crypto.randomBytes(16).toString('hex')
const extension = path.extname(file.originalname).toLowerCase() || '.tmp' // Sanitize extension
cb(null, `${Date.now()}-${uniqueSuffix}${extension}`)
},
})
// Configure multer upload with validation
const upload = multer({
storage,
fileFilter: (req, file, cb) => {
// Validate file type (adjust mimetypes as needed)
const allowedTypes = [
'video/mp4',
'video/quicktime',
'video/x-msvideo',
'video/webm',
'video/x-matroska',
]
const allowedExtensions = ['.mp4', '.mov', '.avi', '.webm', '.mkv']
const fileExt = path.extname(file.originalname).toLowerCase()
if (allowedTypes.includes(file.mimetype) && allowedExtensions.includes(fileExt)) {
cb(null, true) // Accept file
} else {
const error = new Error(
'Invalid file type or extension. Allowed types: MP4, MOV, AVI, WebM, MKV.',
)
error.code = 'INVALID_FILE_TYPE'
cb(error, false) // Reject file
}
},
limits: {
fileSize: 200 * 1024 * 1024, // 200MB limit (adjust as needed)
},
}).single('video') // Expect a single file field named 'video'
const app = express()
app.post('/transcode', (req, res, next) => {
upload(req, res, async (err) => {
// --- Input File Handling ---
const inputPath = req.file ? req.file.path : null // Get path only if upload succeeded
// --- Error Handling for Upload ---
if (err) {
console.error('Upload error:', err)
if (inputPath) fs.unlink(inputPath, () => {}) // Clean up partially uploaded file if it exists
if (err instanceof multer.MulterError) {
return res.status(400).json({ error: `Upload error: ${err.message}` })
} else if (err.code === 'INVALID_FILE_TYPE') {
return res.status(400).json({ error: err.message })
}
return res.status(500).json({ error: 'File upload failed due to an unexpected error.' })
}
if (!req.file) {
// This case should ideally be caught by multer, but added for robustness
return res.status(400).json({ error: 'No video file uploaded.' })
}
// --- Transcoding Process ---
const outputFilename = `${path.parse(req.file.filename).name}.mp4`
const outputPath = path.join(TRANSCODED_DIR, outputFilename)
console.log(`Received file: ${inputPath}, starting transcoding to ${outputPath}`)
try {
await transcodeVideoApi(inputPath, outputPath)
console.log(`Transcoding successful: ${outputPath}`)
// --- Send File and Cleanup ---
res.download(outputPath, outputFilename, (downloadErr) => {
if (downloadErr) {
// Log error, but response might already be partially sent
console.error('Error sending file:', downloadErr)
}
// Always attempt cleanup after download attempt (success or failure)
fs.unlink(inputPath, (unlinkErr) => {
if (unlinkErr) console.error('Error cleaning up input file:', unlinkErr.message)
})
fs.unlink(outputPath, (unlinkErr) => {
if (unlinkErr && unlinkErr.code !== 'ENOENT')
console.error('Error cleaning up output file:', unlinkErr.message)
})
})
} catch (transcodeErr) {
console.error('Transcoding process failed:', transcodeErr.message)
// Clean up input file if transcoding failed
fs.unlink(inputPath, (unlinkErr) => {
if (unlinkErr)
console.error('Error cleaning up input file after transcode failure:', unlinkErr.message)
})
// Output file cleanup is handled within transcodeVideoApi on failure
res.status(500).json({ error: 'Video transcoding failed.' })
}
})
})
// Basic error handling middleware for unhandled errors
app.use((error, req, res, next) => {
console.error('Unhandled application error:', error)
res.status(500).json({ error: 'Internal server error.' })
})
const port = process.env.PORT || 3000
app.listen(port, () => console.log(`Server running on http://localhost:${port}`))
This API uses multer
for secure file uploads, validates file types (mimetype and extension) and
sizes, generates unique filenames using crypto
, calls the transcodeVideoApi
function, handles
errors robustly during upload and transcoding, sends the transcoded file back, and ensures cleanup
of temporary files in various scenarios (success, upload error, transcode error, download error).
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 { spawn } = require('node:child_process')
const fs = require('node:fs')
const path = require('node:path')
// Extract thumbnail with error handling
function extractThumbnail(inputPath, outputPath, timestamp = '00:00:01.000') {
return new Promise((resolve, reject) => {
if (!fs.existsSync(inputPath)) {
return reject(new Error(`Input file not found: ${inputPath}`))
}
const outputDir = path.dirname(outputPath)
if (!fs.existsSync(outputDir)) {
try {
fs.mkdirSync(outputDir, { recursive: true })
} catch (err) {
return reject(new Error(`Failed to create output directory: ${err.message}`))
}
}
const ffmpegArgs = [
'-ss',
timestamp, // Seek before input for accuracy
'-i',
inputPath,
'-vframes',
'1', // Extract exactly one frame
'-q:v',
'2', // Output quality (JPEG quality, 1-31, lower is better)
'-f',
'image2', // Force image2 format
outputPath,
]
const ffmpegProcess = spawn('ffmpeg', ffmpegArgs)
let stderrOutput = ''
ffmpegProcess.stderr.on('data', (data) => (stderrOutput += data.toString()))
ffmpegProcess.on('close', (code) => {
if (code === 0 && fs.existsSync(outputPath)) {
// Verify output exists
resolve(outputPath)
} else {
console.error(`Thumbnail extraction failed with code ${code}. Stderr: ${stderrOutput}`)
fs.unlink(outputPath, (err) => {
if (err && err.code !== 'ENOENT')
console.error(`Error deleting failed thumbnail: ${err.message}`)
})
reject(
new Error(`Thumbnail extraction failed with code ${code}. Output file might be missing.`),
)
}
})
ffmpegProcess.on('error', (err) => reject(err))
})
}
// Apply watermark with error handling
function applyWatermark(inputPath, watermarkPath, outputPath, position = '10:10') {
// e.g., 'main_w-overlay_w-10:main_h-overlay_h-10' for bottom-right
return new Promise((resolve, reject) => {
if (!fs.existsSync(inputPath)) {
return reject(new Error(`Input file not found: ${inputPath}`))
}
if (!fs.existsSync(watermarkPath)) {
return reject(new Error(`Watermark file not found: ${watermarkPath}`))
}
const outputDir = path.dirname(outputPath)
if (!fs.existsSync(outputDir)) {
try {
fs.mkdirSync(outputDir, { recursive: true })
} catch (err) {
return reject(new Error(`Failed to create output directory: ${err.message}`))
}
}
const ffmpegArgs = [
'-i',
inputPath,
'-i',
watermarkPath,
'-filter_complex',
`overlay=${position}`,
'-c:a',
'copy', // Copy audio stream without re-encoding
// Add video encoding parameters if needed (e.g., if input isn't MP4 compatible)
// '-c:v', 'libx264', '-crf', '23', '-preset', 'medium',
outputPath,
]
const ffmpegProcess = spawn('ffmpeg', ffmpegArgs)
let stderrOutput = ''
ffmpegProcess.stderr.on('data', (data) => (stderrOutput += data.toString()))
ffmpegProcess.on('close', (code) => {
if (code === 0) {
resolve(outputPath)
} else {
console.error(`Watermarking failed with code ${code}. Stderr: ${stderrOutput}`)
fs.unlink(outputPath, (err) => {
if (err && err.code !== 'ENOENT')
console.error(`Error deleting failed watermarked video: ${err.message}`)
})
reject(new Error(`Watermarking failed with code ${code}`))
}
})
ffmpegProcess.on('error', (err) => reject(err))
})
}
// Usage examples (assuming input.mp4 and watermark.png exist)
// Ensure 'processed' directory exists or is created by the functions
extractThumbnail('input.mp4', path.join('processed', 'thumbnail.jpg'))
.then((path) => console.log(`Thumbnail created at ${path}`))
.catch((err) => console.error('Thumbnail extraction failed:', err.message))
// Create a dummy watermark if needed for testing
const watermarkFile = 'watermark.png'
if (!fs.existsSync(watermarkFile)) {
console.log(`Creating dummy watermark file: ${watermarkFile}`)
// You'll need a real image file for actual watermarking
fs.writeFileSync(watermarkFile, 'dummy png content')
}
applyWatermark('input.mp4', watermarkFile, path.join('processed', 'watermarked.mp4'))
.then((path) => console.log(`Watermarked video created at ${path}`))
.catch((err) => console.error('Watermarking failed:', err.message))
These functions include checks for input files, create output directories, handle FFmpeg errors, log
stderr on failure, and attempt cleanup. Note the -ss
placement before -i
in extractThumbnail
for faster seeking, and the -f image2
flag for clarity.
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, with robust error handling.
const { spawn } = require('node:child_process')
const fs = require('node:fs')
const path = require('node:path')
function streamTranscode(inputPath, outputPath) {
return new Promise((resolve, reject) => {
if (!fs.existsSync(inputPath)) {
return reject(new Error(`Input file not found: ${inputPath}`))
}
const outputDir = path.dirname(outputPath)
if (!fs.existsSync(outputDir)) {
try {
fs.mkdirSync(outputDir, { recursive: true })
} catch (err) {
return reject(new Error(`Failed to create output directory: ${err.message}`))
}
}
const ffmpegArgs = [
'-i',
'pipe:0', // Read input from stdin (pipe 0)
'-c:v',
'libx264',
'-preset',
'medium',
'-crf',
'23',
'-profile:v',
'high',
'-level:v',
'4.0',
'-c:a',
'aac',
'-b:a',
'128k',
'-movflags',
'+faststart',
'-f',
'mp4', // Specify output format when piping stdout
'pipe:1', // Write output to stdout (pipe 1)
]
const ffmpegProcess = spawn('ffmpeg', ffmpegArgs, {
stdio: ['pipe', 'pipe', 'pipe'], // Configure stdin, stdout, stderr as pipes
})
const inputStream = fs.createReadStream(inputPath)
const outputStream = fs.createWriteStream(outputPath)
let stderrOutput = ''
let processClosed = false
let processError = null
// --- Error Handling Setup ---
const cleanup = (error) => {
if (processClosed) return // Prevent multiple calls
processClosed = true
processError = error // Store the first error encountered
// Destroy streams safely
if (!inputStream.destroyed) inputStream.destroy()
if (!outputStream.destroyed) outputStream.destroy()
if (ffmpegProcess.stdin && !ffmpegProcess.stdin.destroyed) ffmpegProcess.stdin.destroy()
if (ffmpegProcess.stdout && !ffmpegProcess.stdout.destroyed) ffmpegProcess.stdout.destroy()
// Terminate FFmpeg process if it's still running
if (ffmpegProcess.exitCode === null) {
console.log('Terminating FFmpeg process due to error...')
ffmpegProcess.kill('SIGKILL') // Force kill on error
}
// Attempt to clean up output file on error
fs.unlink(outputPath, (unlinkErr) => {
if (unlinkErr && unlinkErr.code !== 'ENOENT') {
console.error(
`Error deleting incomplete output file during cleanup: ${unlinkErr.message}`,
)
}
// Reject the main promise *after* cleanup attempts
reject(error || new Error('Unknown streaming error'))
})
}
// Handle input stream errors
inputStream.on('error', (err) => {
console.error(`Input stream error: ${err.message}`)
cleanup(new Error(`Input stream error: ${err.message}`))
})
// Handle output stream errors
outputStream.on('error', (err) => {
console.error(`Output stream error: ${err.message}`)
cleanup(new Error(`Output stream error: ${err.message}`))
})
// Handle output stream finishing *before* FFmpeg closes (can indicate issues)
outputStream.on('finish', () => {
console.log('Output stream finished.')
// If FFmpeg hasn't closed yet, something might be wrong, but wait for 'close' event.
})
// Handle FFmpeg stderr
ffmpegProcess.stderr.on('data', (data) => {
stderrOutput += data.toString()
// console.log(`ffmpeg stderr: ${data}`); // Log progress/details if needed
})
// Handle FFmpeg process errors (e.g., command not found)
ffmpegProcess.on('error', (err) => {
console.error(`FFmpeg process failed to start or errored: ${err.message}`)
cleanup(new Error(`FFmpeg process error: ${err.message}`))
})
// Handle FFmpeg process exit
ffmpegProcess.on('close', (code) => {
if (processClosed && processError) return // Error already handled by cleanup
processClosed = true
if (code === 0) {
console.log(`Stream transcoding finished successfully: ${outputPath}`)
resolve(outputPath)
} else {
console.error(`Stream transcoding failed with code ${code}`)
console.error(`ffmpeg stderr:\\n${stderrOutput}`)
const error = new Error(`Stream transcoding failed with code ${code}`)
// Attempt cleanup, then reject
fs.unlink(outputPath, (unlinkErr) => {
if (unlinkErr && unlinkErr.code !== 'ENOENT') {
console.error(
`Error deleting incomplete output file after FFmpeg close: ${unlinkErr.message}`,
)
}
reject(error)
})
}
})
// --- Pipe Streams ---
console.log(`Piping ${inputPath} to FFmpeg and to ${outputPath}`)
inputStream.pipe(ffmpegProcess.stdin)
ffmpegProcess.stdout.pipe(outputStream)
// --- Graceful Shutdown Handling ---
const handleSignal = (signal) => {
console.log(`Received ${signal}, attempting graceful shutdown...`)
if (!processClosed) {
cleanup(new Error(`Process terminated by ${signal}`))
} else {
console.log('Process already closing.')
}
}
process.on('SIGINT', handleSignal)
process.on('SIGTERM', handleSignal)
})
}
// Example usage:
const largeInputVideo = 'large_input.mkv' // Replace with your large input video
const largeOutputVideo = path.join('processed', 'large_output.mp4')
// Create a dummy large input file for testing if it doesn't exist
if (!fs.existsSync(largeInputVideo)) {
console.log(`Creating dummy large input file: ${largeInputVideo}`)
try {
// You'll need a real video file for actual streaming transcoding
fs.writeFileSync(largeInputVideo, 'dummy large content')
} catch (err) {
console.error(`Failed to create dummy large input file: ${err.message}`)
process.exit(1)
}
}
streamTranscode(largeInputVideo, largeOutputVideo)
.then((path) => console.log(`Streaming transcoding completed: ${path}`))
.catch((err) => console.error('Streaming transcoding failed:', err.message))
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. Graceful shutdown on signals like SIGINT/SIGTERM is also
included.
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.