Automate animated GIF previews on the CLI
For a directory of videos, generate a short GIF for each file and keep its relative path in the
output directory. The Bash script below previews the first three seconds at 10 fps, preserves
existing files, and returns a failure status if any conversion fails. Two videos named demo.mp4
in different folders get separate previews.
Set up the Linux tools
Use Bash, GNU findutils and coreutils, and FFmpeg with ffprobe. The examples target Linux and a
case-sensitive filesystem with hard-link support; native macOS and PowerShell are outside this
walkthrough’s tested scope. Use ordinary square-pixel SDR videos. HDR tone mapping and correcting
anamorphic video need a separate filter setup.
These examples were tested with FFmpeg 6.1.1 on Ubuntu 24.04 and FFmpeg n9.0.1 on Linux.
On Ubuntu or Debian, install the tools with:
sudo apt-get update && sudo apt-get install ffmpeg bash findutils coreutils
The Ubuntu FFmpeg package includes the media tools. Check the versions available in your shell:
bash --version
ffmpeg -version
ffprobe -version
Choose GIF when the destination needs it
GIF is useful for a looping illustration in documentation or a system that accepts images but cannot embed video. It has a limited palette and no audio. A GIF can be much larger than its MP4 source, so compare actual files before using it for web previews. If your destination supports video, retaining a short video clip may give better quality at a smaller size. Google’s GIF-to-video comparison illustrates the potential size difference.
Create one bounded preview
Put a video named input.mp4 in your working directory, then run:
(
if [[ -e ./preview.gif || -L ./preview.gif ]]; then
printf 'Refusing existing output: ./preview.gif\n' >&2
exit 1
fi
ffmpeg -hide_banner -loglevel error -nostdin -n -xerror -abort_on empty_output \
-t 3 -i ./input.mp4 -map 0:v:0 \
-vf "fps=10,scale='min(320,iw)':-1:flags=lanczos,split[a][b];[a]palettegen[p];[b][p]paletteuse" \
-t 3 -frames:v 30 -loop 0 -f gif ./preview.gif
)
This selects the first video stream, limits the width to 320 pixels without enlarging smaller
inputs, and creates an infinitely looping GIF. The input-side -t 3 bounds the segment fed to
the filters; the output duration and frame cap keep the preview to at most three seconds and
30 frames. Short inputs produce shorter previews. See FFmpeg’s
input and output option rules.
The split sends the scaled frames to both palettegen and paletteuse: one builds a palette
from the clip, and the other applies it. Limiting the input matters because generating a palette
for the whole video would delay output and increase buffering. The
palette filter documentation describes the
available color and dithering controls.
The shell check reports an existing preview.gif as a failure; -n also tells FFmpeg not to
overwrite it. FFmpeg can return zero for an overwrite refusal in some versions, so its status
alone is insufficient here. -nostdin disables interactive input. If conversion fails after
creating the file, it may leave an incomplete GIF. The batch script below avoids publishing
partial output by converting to a temporary file first.
Inspect a successful result with:
ffprobe -v error -select_streams v:0 -count_frames \
-show_entries stream=width,height,nb_read_frames:format=duration,size \
-of json ./preview.gif
For an input lasting at least three seconds, expect 30 frames and a duration of 3.000000 seconds.
Open the GIF as well: metadata does not tell you whether the chosen opening scene is useful.
Batch the directory without losing filenames
Save this complete script as gif-previews.sh. It recursively processes regular files ending in
.mp4, case-insensitively, without following symlink entries. It appends .gif to the full
relative filename: videos/team/demo.mp4 becomes gifs/team/demo.mp4.gif. Keeping the extension
also distinguishes demo.mp4 from demo.MP4 on the supported filesystem.
#!/usr/bin/env bash
set -euo pipefail
if (( $# > 2 )); then
printf 'Usage: bash gif-previews.sh [video-directory] [output-directory]\n' >&2
exit 1
fi
VIDEO_DIR=${1:-./videos}
OUTPUT_DIR=${2:-./gifs}
command -v ffmpeg >/dev/null
# Resolve both directory arguments from the caller's working directory.
start_dir=$PWD
cd -P -- "$VIDEO_DIR"
VIDEO_DIR=$PWD
cd -P -- "$start_dir"
mkdir -p -- "$OUTPUT_DIR"
cd -P -- "$OUTPUT_DIR"
OUTPUT_DIR=$PWD
cd -P -- "$VIDEO_DIR"
work=$(mktemp -d -- "$OUTPUT_DIR/.gif-preview.XXXXXX")
trap 'rm -rf -- "$work"' EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
# Finish discovery first so a failed find cannot look like an empty successful batch.
find . -type f -iname '*.mp4' -print0 > "$work/files"
if [[ ! -s "$work/files" ]]; then
printf 'No MP4 files found.\n' >&2
exit 1
fi
generated=0
failed=0
while IFS= read -r -d '' video; do
output="$OUTPUT_DIR/${video#./}.gif"
if [[ -e "$output" || -L "$output" ]]; then
printf 'Refusing existing output: %q\n' "$output" >&2
failed=$((failed + 1))
continue
fi
if mkdir -p -- "${output%/*}" &&
ffmpeg -hide_banner -loglevel error -nostdin -y -xerror -abort_on empty_output \
-t 3 -i "$video" -map 0:v:0 \
-vf "fps=10,scale='min(320,iw)':-1:flags=lanczos,split[a][b];[a]palettegen[p];[b][p]paletteuse" \
-t 3 -frames:v 30 -loop 0 -f gif "$work/preview.gif" &&
ln -T -- "$work/preview.gif" "$output"; then
printf 'Generated: %q\n' "$output"
generated=$((generated + 1))
else
printf 'Failed: %q\n' "$video" >&2
failed=$((failed + 1))
fi
# Unlink the temporary name before reuse: published files share its inode.
rm -f -- "$work/preview.gif"
done < "$work/files"
printf 'Generated: %d; failed: %d\n' "$generated" "$failed"
if (( failed > 0 )); then
exit 1
fi
Run it from the directory containing your videos folder:
bash gif-previews.sh ./videos ./gifs
The script preserves spaces, newlines, leading hyphens, and literal percent signs in filenames.
Null-delimited discovery keeps each filename intact, and FFmpeg cannot consume the loop’s input
because of -nostdin. Both discovery and conversion run from the same physically resolved input
directory, including when the directory argument contains a symlink followed by ...
Existing destinations count as failures; they are never silently skipped or replaced. Conversion
uses -y only for the private temporary file. GNU ln -T publishes a completed GIF without
replacing a destination that appears during conversion. The temporary directory lives on the
output filesystem so hard links can work. Use an output tree you control, without symlinked
subdirectories or nested mounts.
A mixed batch keeps its successful GIFs and continues after per-file failures, then exits with
status 1. An empty input directory also exits with status 1. A rerun against the same output tree
reports the existing files as failures; choose a new output directory to regenerate the whole
batch. Ordinary failures and interrupts clean up temporary files, but a forced kill or power loss
can leave a hidden .gif-preview.* directory.
Adjust the size and quality budget
Start with the three-second, 320-pixel, 10-fps settings and inspect representative clips. Smaller
dimensions remove detail; a lower frame rate makes motion less smooth. Shortening the clip often
saves more than tuning colors. If you change the rate or duration, update both -t values and
the -frames:v cap together; for two seconds at 8 fps, use a cap of 16.
The default paletteuse dithering helps gradients but can add visible noise. Its documented
dither=bayer setting is worth comparing on your own footage. These controls trade detail,
motion, and color accuracy for size; none promises that GIF will beat a video codec.
Diagnose a failed batch
- Existing output: choose a fresh output directory, or deliberately remove only the previews you want to regenerate. Changing quality settings does not overwrite old results.
- Invalid video or no video stream: inspect the named source with
ffprobe. Renaming a corrupt file to.mp4does not repair it. The script checks the preview segment, not the health of the entire video. - No frames or an unhelpful opening: very short or unusual inputs may yield no preview; the script treats empty output as a failure. A black opening needs a different segment.
- Filesystem or memory errors: check free space, output permissions, and hard-link support. Reduce the segment length and dimensions for large sources. A three-second clip limit is not a hard limit on decoder memory or execution time.
Use the printed file paths to inspect failures, then review the successful GIFs before putting them into your media catalog.
