Concatenate videos in Scala with FFmpeg
To join clips exported with the same settings, call FFmpeg’s concat demuxer from Scala and copy the video and audio streams into one MP4. The program below checks the inputs, joins them in argument order, and returns a nonzero exit status when validation or FFmpeg fails. It refuses to overwrite an existing destination.
Set up your environment
This walkthrough uses Linux x86-64, Bash, OpenJDK 21.0.12.1, Scala CLI 1.17.1, and Scala 3.3.7. The native
media checks use FFmpeg and ffprobe 9.0.1. Have java, ffmpeg, ffprobe, curl, and gzip
on your PATH; the sample-clip commands also need FFmpeg’s libx264 and aac encoders.
Install Scala
Use the Scala CLI Linux release
to keep the launcher local to this example. Paste this block into Bash from a directory where you
can create a new project. If scala-video-concat already exists, choose a different parent
directory; setup stops without changing that project.
(
set -eu
mkdir scala-video-concat
cd scala-video-concat
curl -fsSLo scala-cli.gz https://github.com/VirtusLab/scala-cli/releases/download/v1.17.1/scala-cli-x86_64-pc-linux.gz
gzip -d scala-cli.gz
chmod +x scala-cli
./scala-cli version
)
Stay in the parent directory for the remaining shell commands. The parentheses keep each block’s
directory changes local. Save the Scala code below as scala-video-concat/Concat.scala; its
using scala directive
pins the compiler. The first run downloads the compiler and standard library, so it needs network
access. There are no application-library dependencies. --jvm system uses your installed JDK,
and --server=false disables the background compilation server.
Install FFmpeg
Install an FFmpeg build for your distribution using the FFmpeg download page. Confirm that both tools run:
java -version && ffmpeg -version && ffprobe -version
Video concatenation methods
The concat demuxer reads a list of files as one
input. With -c copy, FFmpeg remuxes their compressed packets without encoding them again.
Matching filename extensions alone are insufficient: the streams need compatible codecs,
configuration, and time bases.
This example accepts complete local MP4 clips with exactly one H.264 video stream followed by one AAC audio stream. Export them with the same settings, including dimensions, frame rate, pixel format, audio sample rate, and channel layout. Use clips with aligned audio and video durations; the demuxer offsets each next clip using the preceding file’s duration, so unequal track lengths can leave gaps. Rotated clips and additional tracks are outside this example’s scope.
The preflight compares selected stream properties and codec configuration hashes from ffprobe. It deliberately rejects mismatches instead of attempting conversion. This is a compatibility check, not a full corruption scan: stream copying does not decode every frame, and matching headers cannot establish that every packet is valid.
Save the complete Scala program
//> using scala 3.3.7
import java.nio.charset.StandardCharsets.UTF_8
import java.nio.file.{Files, LinkOption, Path, Paths}
import java.util.Locale
import scala.sys.process.Process
import scala.util.control.NonFatal
object Concat:
def checkedPath(value: String): Path =
require(!value.exists(c => c == '\n' || c == '\r'), "Paths cannot contain line breaks")
Paths.get(value).toAbsolutePath
def signature(input: Path): String =
val fields = "codec_name,codec_type,profile,level,codec_tag_string,width,height," +
"pix_fmt,sample_aspect_ratio,field_order,r_frame_rate,time_base," +
"sample_fmt,sample_rate,channels,channel_layout,extradata_hash"
val info = Process(Seq(
"ffprobe", "-v", "error", "-show_data_hash", "sha256",
"-show_entries", s"stream=$fields:stream_side_data=rotation",
"-of", "default=noprint_wrappers=1", input.toString
)).!!
val lines = info.linesIterator.toVector
require(
lines.filter(_.startsWith("codec_name=")) == Vector("codec_name=h264", "codec_name=aac"),
s"Expected one H.264 video followed by one AAC audio stream: $input"
)
require(!lines.exists(_.startsWith("rotation=")), s"Rotated video is unsupported: $input")
require(lines.count(_.startsWith("extradata_hash=")) == 2, s"Missing codec configuration: $input")
info
def concatenate(args: Array[String]): Unit =
require(args.length >= 3, "Usage: Concat <output.mp4> <input1.mp4> <input2.mp4> [more.mp4 ...]")
val output = checkedPath(args(0))
require(output.toString.toLowerCase(Locale.ROOT).endsWith(".mp4"), "Output must end in .mp4")
require(!Files.exists(output, LinkOption.NOFOLLOW_LINKS), s"Output already exists: $output")
require(Files.isDirectory(output.getParent), s"Output directory does not exist: ${output.getParent}")
val inputs = args.drop(1).toVector.map { value =>
val path = checkedPath(value).toRealPath()
checkedPath(path.toString)
require(Files.isRegularFile(path) && Files.isReadable(path), s"Not a readable file: $path")
require(path.toString.toLowerCase(Locale.ROOT).endsWith(".mp4"), s"Input must end in .mp4: $path")
path
}
val first = signature(inputs.head)
for input <- inputs.tail do
require(signature(input) == first, s"Stream settings differ from the first clip: $input")
val list = Files.createTempFile("scala-concat-", ".ffconcat")
try
val entries = inputs.map { path =>
val escaped = path.toString.replace("'", "'\\''")
s"file '$escaped'"
}
Files.writeString(list, entries.mkString("\n") + "\n", UTF_8)
val status = Process(Seq(
"ffmpeg", "-hide_banner", "-loglevel", "warning", "-nostdin", "-n",
"-f", "concat", "-safe", "0", "-i", list.toString,
"-map", "0:v:0", "-map", "0:a:0", "-c", "copy", output.toString
)).!
if status != 0 then throw IllegalStateException(s"FFmpeg failed (exit $status)")
finally
Files.deleteIfExists(list)
println(s"Created $output")
def main(args: Array[String]): Unit =
try concatenate(args)
catch
case NonFatal(error) =>
Console.err.println(s"Concatenation failed: ${error.getMessage}")
sys.exit(1)
Process(Seq(...)) passes separate arguments without a shell. The temporary list has its own
FFmpeg quoting rules, so the code also escapes apostrophes there. Spaces, apostrophes, and leading
hyphens in local filenames work; line breaks are rejected. -safe 0 permits the absolute paths in
the list generated by this program. Use your own trusted clips and keep them unchanged during a run.
Run it with two clips
Put your clips in scala-video-concat, or generate these two small inputs there. The first is red
with a 440 Hz tone; the second is blue with an 880 Hz tone. Each lasts two seconds. These commands
also refuse to replace existing files.
(
set -eu
cd scala-video-concat
for clip in 'part 1.mp4' 'part 2.mp4'; do
if [ -e "$clip" ] || [ -L "$clip" ]; then
printf 'Sample already exists: %s\n' "$clip" >&2
exit 1
fi
done
ffmpeg -hide_banner -loglevel error -nostdin -n \
-f lavfi -i 'color=c=red:s=320x240:r=25:d=2' \
-f lavfi -i 'sine=frequency=440:sample_rate=48000:duration=2' \
-c:v libx264 -pix_fmt yuv420p -c:a aac -ac 2 -shortest 'part 1.mp4'
ffmpeg -hide_banner -loglevel error -nostdin -n \
-f lavfi -i 'color=c=blue:s=320x240:r=25:d=2' \
-f lavfi -i 'sine=frequency=880:sample_rate=48000:duration=2' \
-c:v libx264 -pix_fmt yuv420p -c:a aac -ac 2 -shortest 'part 2.mp4'
)
Run the saved program from the parent directory. Arguments after -- belong to Concat:
the output comes first, followed by inputs in playback order. Add more input paths to join more clips.
(
cd scala-video-concat &&
./scala-cli run Concat.scala --server=false --jvm system -- \
joined.mp4 'part 1.mp4' 'part 2.mp4'
)
On success, the program prints the absolute path of joined.mp4 and exits with status zero.
For the sample clips, expect roughly four seconds of 320-by-240 video with stereo audio: red and
the lower tone first, then blue and the higher tone. AAC packet timing can make the container
duration slightly longer than four seconds. FFmpeg can adjust overlapping AAC packet timestamps at
the join and print a warning. Stream copying is not a guarantee of sample-accurate, gapless audio;
use an audio decoding and encoding pipeline when that is required. Inspect the streams and decode
the whole result:
(
cd scala-video-concat &&
ffprobe -v error -show_entries stream=codec_name,width,height,sample_rate,channels:format=duration \
-of json joined.mp4 &&
ffmpeg -hide_banner -loglevel error -nostdin -xerror -i joined.mp4 \
-map 0:v:0 -map 0:a:0 -f null -
)
Error handling and optimization
Missing inputs, unreadable media, unsupported tracks, and differing stream settings produce an
error on stderr and exit status one. The FFmpeg diagnostic appears before the program’s failure
message if probing or joining fails. Check the shell status immediately with echo $? when running
interactively, or use the program’s status in your calling job.
The destination check runs before input validation. A rerun with joined.mp4 already present fails
and preserves that file, even if an input is missing. FFmpeg’s
-n option adds an overwrite refusal when it starts;
-nostdin prevents interactive prompts. Run one writer per destination. This example does not
coordinate concurrent processes.
The temporary list is removed on ordinary success or failure. If FFmpeg fails after opening the destination, a partial MP4 can remain. Inspect and remove that failed output yourself, or choose a new name before retrying. An abrupt process termination can also leave temporary files. A zero exit status reports that the remux completed; use the decode check and playback to check media integrity and the join.
Handle clips that need conversion
If the stream check rejects your clips, re-export them with consistent settings before using this program. For mixed dimensions, missing audio, or different codecs, a separate decoding and encoding pipeline is more appropriate. FFmpeg’s concat filter requires segments starting at timestamp zero and compatible dimensions; missing audio needs an explicit policy, such as adding silence. Switching from the demuxer to that filter alone does not normalize arbitrary inputs.
Crossfades also require re-encoding and change the combined duration because clips overlap. The xfade filter has video format and time-base requirements, and audio needs its own transition. The saved program performs straight cuts and retains both tracks; it does not implement a transition mode.
