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.