Concatenate videos in Scala with FFmpeg
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:
- Concat demuxer: Ideal for videos with the same codec and format
- 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 =>
val path = Paths.get(video.path).toAbsolutePath.toString
require(!path.contains('\n') && !path.contains('\r'), "Paths must not contain newlines")
val escaped = path.replace("'", "'\\''")
writer.println(s"file '$escaped'")
}
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 = Files.createTempFile("video-concat-", ".txt").toString
createConcatFile(videos, tempFile).get
val cmd = Seq(
"ffmpeg",
"-f", "concat",
"-safe", "0",
"-i", tempFile,
"-c", "copy",
"-y",
output
)
try cmd.! == 0
finally Files.deleteIfExists(Paths.get(tempFile))
case ConcatMethod.Filter =>
val inputs = videos.flatMap(v => Seq("-i", v.path))
val inputsForFilter = videos.indices.map(i => s"[$i:v:0][$i:a:0]").mkString
val filter = s"${inputsForFilter}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:
- Creating a text file listing input videos
- Using FFmpeg's concat demuxer to combine them
- 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 codecs after normalizing the video dimensions
This filter example requires one video and one audio stream per input, matching video dimensions, and timestamps starting at zero. Normalize those properties first. It does not add transitions.
Error handling and optimization
Our implementation includes several error handling features:
- Input validation
- File existence checks
- Proper resource cleanup
- 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
The following video-only example crossfades exactly two clips of matching dimensions and frame
rate. Supply the first clip's duration in seconds; audio is deliberately omitted with -an.
def concatenateWithTransitions(
first: VideoFile,
second: VideoFile,
firstDuration: Double,
output: String,
transitionDuration: Double = 1.0
): Try[Boolean] = Try {
require(transitionDuration > 0 && firstDuration > transitionDuration)
val offset = firstDuration - transitionDuration
val filter = "[0:v]settb=AVTB,setpts=PTS-STARTPTS[v0];" +
"[1:v]settb=AVTB,setpts=PTS-STARTPTS[v1];" +
s"[v0][v1]xfade=transition=fade:duration=$transitionDuration:offset=$offset[outv]"
val cmd = Seq("ffmpeg", "-i", first.path, "-i", second.path,
"-filter_complex", filter,
"-map", "[outv]", "-an",
"-y",
output
)
cmd.! == 0
}
Handling different formats
This example normalizes codecs, a 1280-by-720 canvas, frame rate, timestamps, and audio format. Each input must contain an audio stream; add silence separately for clips without audio.
def normalizeVideo(input: VideoFile, output: String): Try[Boolean] = Try {
val cmd = Seq(
"ffmpeg",
"-i", input.path,
"-vf", "scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=30,setpts=PTS-STARTPTS",
"-af", "asetpts=PTS-STARTPTS", "-ar", "48000", "-ac", "2",
"-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.
