Creating montage videos with FFMPEG

Last updated
30 December 2022
Ben Talbot

A media collection of 100s of photos and videos, ready to be combined with FFMPEG

I recently had to create a video montage at a pinch—bringing hundreds of photos and videos from a media collection into a single video. Straight away FFMPEG was calling me. Surely there's a single one liner that could put this together?

As far as I could tell, that didn't seem to be the case. Straight away the concat demuxer appealed to me, but the with same codecs caveat seemed to necessitate pre-processing steps. Likewise, the examples on the wiki showed pre-processing steps even for the concat protocol.

Given this, I let go of the one-line dream, and set to work on the following process:

  1. convert all videos to a standard format;
  2. create videos for each of the images, in the same standard format; and
  3. call the concat demuxer to turn all the temporary videos into a single output.

Let's start the script with some set-up. Turn on some Bash settings to catch common errors, declare some configurable settings, and clean up outputs from previous runs:

#!/usr/bin/env bash set -euo pipefail SECONDS_PER_IMAGE=5 TMP_DIR=".tmp" FILES_LIST="$TMP_DIR/.files" OUTPUT="output.mp4" VF="scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,fps=30" rm -r "$TMP_DIR" "$OUTPUT" || true mkdir "$TMP_DIR"

Where $VF is the standard format chosen: 1920×1080 @ 30FPS, with black bars placed around images not matching the video's aspect ratio.

Next, all MP4 videos were found in the media collection, converted to a video in the chosen standard format saved to $TMP_DIR, and the new video's filename added to the final output video's $FILES_LIST:

find . -maxdepth 1 -regex '.*\(mp4\|MP4\)' | while read i; do ffmpeg -y -i "$i" -vf "$VF" -ar 44100 "$TMP_DIR/$i" < /dev/null echo "file '$i'" >> "$FILES_LIST" done

The same is then done for all images (JPGs, PNGs, and BMPs) in the media collection, with the -loop 1 flag telling FFMPEG to loop over the image until -t $SECONDS_PER_IMAGE seconds have elapsed:

find . -maxdepth 1 -regex '.*\(jpe?g\|png\|JPG\|bmp\)' | while read i; do ffmpeg -y -loop 1 -i "$i" -t "$SECONDS_PER_IMAGE" -vf "$VF" \ "$TMP_DIR/$i.mp4" < /dev/null echo "file '$i.mp4'" >> "$FILES_LIST" done

Currently, all the media would appear in the same order with videos at the start. The shuf command line tool is then used to shuffle $FILES_LIST in place:

shuf -o "$FILES_LIST" "$FILES_LIST"

One step left, concatenating the videos, right? I hit a bump in the road here, with the concat demuxer emitting a video with no audio if the first video had no audio. This happened a lot given most of the videos in $FILES_LIST were audio-less videos made from the media collection's images.

Although this could've been solved within FFMPEG (using anullsrc to add blank audio to all videos in $FILES_LIST, or to a single frame video at the start of $OUTPUT), I fell back to Bash. The following snippet uses ffprobe to find the first video in $FILES_LIST with audio and moves it to the top of the list:

first= while read f; do fn="$(echo "$f" | sed "s/^file '//; s/'$//")" if ffprobe "$fn" 2>&1 | grep -q 'Audio:'; then first="$fn" fi done < "$FILES_LIST" if [ -n "$first" ]; then { echo "file '$first'"; cat "$FILES_LIST"; } | awk '!seen[$0]++' > files mv files "$FILES_LIST" fi

Now the final step can take place: concatenating the videos in $FILES_LIST into a single $OUTPUT video:

ffmpeg -y -f concat -safe 0 -i "$FILES_LIST" "$OUTPUT"

A copy of the full script is available in the drop-down box below. Please note, the script assumes the media collection is located in the directory where the script is run.

 Full script
#!/usr/bin/env bash set -euo pipefail SECONDS_PER_IMAGE=5 TMP_DIR=".tmp" FILES_LIST="$TMP_DIR/.files" OUTPUT="output.mp4" VF="scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,fps=30" rm -r "$TMP_DIR" "$OUTPUT" || true mkdir "$TMP_DIR" find . -maxdepth 1 -regex '.*\(mp4\|MP4\)' | while read i; do ffmpeg -y -i "$i" -vf "$VF" -ar 44100 "$TMP_DIR/$i" < /dev/null echo "file '$i'" >> "$FILES_LIST" done find . -maxdepth 1 -regex '.*\(jpe?g\|png\|JPG\|bmp\)' | while read i; do ffmpeg -y -loop 1 -i "$i" -t "$SECONDS_PER_IMAGE" -vf "$VF" \ "$TMP_DIR/$i.mp4" < /dev/null echo "file '$i.mp4'" >> "$FILES_LIST" done shuf -o "$FILES_LIST" "$FILES_LIST" first= while read f; do fn="$(echo "$f" | sed "s/^file '//; s/'$//")" if ffprobe "$fn" 2>&1 | grep -q 'Audio:'; then first="$fn" fi done < "$FILES_LIST" if [ -n "$first" ]; then { echo "file '$first'"; cat "$FILES_LIST"; } | awk '!seen[$0]++' > files mv files "$FILES_LIST" fi ffmpeg -y -f concat -safe 0 -i "$FILES_LIST" "$OUTPUT"

FFMPEG is a tremendously powerful tool, one that requires a lot of practised knowledge to use effectively. Hopefully this brain dump helps point you in the right direction for whatever video creation problem you're currently trying to solve. And hopefully the notes help me next time FFMPEG comes calling.


Let me know below if you liked this content, or found it valuable, using the buttons below.

Like this?

Loading donation link...

© Ben Talbot. All rights reserved.