Video concatenation is a common requirement in media processing applications, whether you're building a video editor, creating compilations, or automating video workflows. In this guide, we'll explore how to concatenate videos efficiently using Scala and FFmpeg, covering different methods and best practices.

Set up your environment

Install Scala

The recommended way to install Scala is using Coursier (cs setup):

macOS:

brew install coursier/formulas/coursier && cs setup

Linux:

curl -fL https://github.com/coursier/coursier/releases/latest/download/cs-x86_64-pc-linux.gz | gzip -d > cs && chmod +x cs && ./cs setup

Windows: Download and execute the Scala installer for Windows

Install FFmpeg

macOS:

brew install ffmpeg

Linux (Ubuntu/Debian):

sudo apt update && sudo apt install ffmpeg

Windows: Download from the official FFmpeg website

Video concatenation methods

FFmpeg offers two main methods for concatenating videos:

  1. Concat demuxer: Ideal for videos with the same codec and format
  2. Concat filter: More flexible but requires re-encoding

Let's implement both methods in Scala:

import scala.sys.process.*
import scala.util.{Try, Success, Failure}
import java.io.{File, PrintWriter}
import java.nio.file.{Files, Paths}

enum ConcatMethod:
  case Demuxer, Filter

case class VideoFile(path: String):
  def exists: Boolean = Files.exists(Paths.get(path))
  def extension: String = path.split("\\.").lastOption.getOrElse("")

object VideoConcatenator:
  def createConcatFile(videos: Seq[VideoFile], tempFile: String): Try[Unit] = Try {
    val writer = PrintWriter(File(tempFile))
    try
      videos.foreach(video => writer.println(s"file '${video.path}'"))
    finally
      writer.close()
  }

  def concatenateVideos(
    videos: Seq[VideoFile],
    output: String,
    method: ConcatMethod = ConcatMethod.Demuxer
  ): Try[Boolean] = Try {
    if videos.isEmpty then throw IllegalArgumentException("No videos provided")
    if !videos.forall(_.exists) then throw IllegalArgumentException("One or more input videos not found")

    method match
      case ConcatMethod.Demuxer =>
        val tempFile = "concat_list.txt"
        createConcatFile(videos, tempFile).get

        val cmd = Seq(
          "ffmpeg",
          "-f", "concat",
          "-safe", "0",
          "-i", tempFile,
          "-c", "copy",
          "-y",
          output
        )

        val result = cmd.! == 0
        Files.deleteIfExists(Paths.get(tempFile))
        result

      case ConcatMethod.Filter =>
        val inputs = videos.flatMap(v => Seq("-i", v.path))
        val filter = s"concat=n=${videos.length}:v=1:a=1[outv][outa]"

        val cmd = Seq("ffmpeg") ++ inputs ++ Seq(
          "-filter_complex", filter,
          "-map", "[outv]",
          "-map", "[outa]",
          "-y",
          output
        )

        cmd.! == 0
  }

  @main def run(args: String*): Unit =
    if args.length < 3 then
      println("Usage: VideoConcatenator <output_file> <input_file1> <input_file2> [input_file3...] [--filter]")
      sys.exit(1)

    val useFilter = args.contains("--filter")
    val output = args(0)
    val videos = args.slice(1, args.length).filterNot(_ == "--filter").map(VideoFile.apply)
    val method = if useFilter then ConcatMethod.Filter else ConcatMethod.Demuxer

    concatenateVideos(videos, output, method) match
      case Success(true) => println(s"Successfully concatenated videos to $output")
      case Success(false) => println("Failed to concatenate videos: FFmpeg returned non-zero exit code")
      case Failure(e) => println(s"Error concatenating videos: ${e.getMessage}")

Understanding the methods

Concat demuxer

The concat demuxer is faster as it doesn't require re-encoding. It works by:

  1. Creating a text file listing input videos
  2. Using FFmpeg's concat demuxer to combine them
  3. Copying streams directly to the output

This method is ideal when your videos share the same codec and format.

Concat filter

The concat filter is more flexible but slower as it requires re-encoding. Use this method when:

  • Videos have different codecs
  • You need to add transitions
  • You're working with different formats or resolutions

Error handling and optimization

Our implementation includes several error handling features:

  1. Input validation
  2. File existence checks
  3. Proper resource cleanup
  4. Detailed error reporting

For better performance:

  • Use the demuxer method when possible
  • Ensure input videos are in the same format
  • Consider using hardware acceleration with -hwaccel
  • Clean up temporary files

Advanced techniques

Adding transitions

To add crossfade transitions between videos:

def concatenateWithTransitions(
  videos: Seq[VideoFile],
  output: String,
  transitionDuration: Double = 1.0
): Try[Boolean] = Try {
  val inputs = videos.flatMap(v => Seq("-i", v.path))
  val filters = videos.indices.map(i =>
    if i == 0 then s"[$i:v]setpts=PTS-STARTPTS[v$i]"
    else s"[$i:v]setpts=PTS-STARTPTS[v$i];[v${i-1}][v$i]xfade=transition=fade:duration=$transitionDuration:offset=${i*5}[v${i+1}]"
  )

  val cmd = Seq("ffmpeg") ++ inputs ++ Seq(
    "-filter_complex", filters.mkString(";"),
    "-map", s"[v${videos.length}]",
    "-y",
    output
  )

  cmd.! == 0
}

Handling different formats

When working with different formats, you might need to normalize them first:

def normalizeVideo(input: VideoFile, output: String): Try[Boolean] = Try {
  val cmd = Seq(
    "ffmpeg",
    "-i", input.path,
    "-c:v", "libx264",
    "-c:a", "aac",
    "-y",
    output
  )

  cmd.! == 0
}

Conclusion

Video concatenation in Scala using FFmpeg offers a powerful way to combine video files programmatically. The choice between the concat demuxer and filter methods depends on your specific requirements for speed versus flexibility.

For more advanced video processing capabilities, including automated video concatenation at scale, check out Transloadit's Video Encoding service.