Skip to content

Instantly share code, notes, and snippets.

@tomoe-mami
Last active January 15, 2025 05:38
Show Gist options
  • Save tomoe-mami/3748855 to your computer and use it in GitHub Desktop.
Save tomoe-mami/3748855 to your computer and use it in GitHub Desktop.
Simple Video Thumbnail/Screencap Generator
#!/bin/sh
#
# video-thumbnails: A simple shell script to generate video thumbnails/screencaps
#
# Author: rumia <https://github.com/rumia>
# License: WTFPL
#
function quit {
rm -rf "$1"
exit
}
function get_seconds {
if [[ "$1" == *:* ]]; then
local tm_rev="$(echo "$1" | rev)"
local ss="$(echo "$tm_rev" | cut -d: -f1 | rev)"
local mm="$(echo "$tm_rev" | cut -d: -f2 | rev)"
local hh="$(echo "$tm_rev" | cut -d: -f3 | rev)"
local hhmmss="$(printf "%02d:%02d:%02d" "${hh:-0}" "${mm:-0}" "${ss:-0}")"
date +%s -d "1970-01-01 $hhmmss UTC"
else
echo $1
fi
}
usage="Generate screencaps for a video
Usage: $(basename "$0") [options] video-filename [interval-in-seconds]
Options:
-t w[xh] Numbers of tile (default: 4)
-q quality Image quality (default: 98)
-d n Divisor for frame dimension (default: 2)
-b color Background color (default: white)
-f font Default font (default: Droid-Sans-Regular)
-s size Default text size (default: 15)
-p position Timestamp position (default: NorthWest)
-c color Timestamp color (default: rgba(255, 255, 255, 0.7))
-o color Timestamp outline color (default: rgba(0, 0, 0, 0.2))
-w px Timestamp outline width (default: 2)
-F font Header font (will use default font if not specified)
-S size Header text size (will use default text size if not specified)
-P position Header text position (default: NorthWest)
-C color Header text color (default: black)
-B color Header background color (the default is the color specified by -b)
-k n Skip n seconds before capturing
-h Display this help
"
text_font="Droid-Sans-Regular"
text_size=15
timestamp_color="rgba(255, 255, 255, 0.7)"
timestamp_offset="+10+5"
timestamp_position="NorthWest"
outline_color="rgba(0, 0, 0, 0.2)"
outline_width=2
quality=98
tile=4
dim_divisor=2
background="white"
skip_sec=0
while getopts ":b:f:s:c:p:o:w:q:t:d:F:S:P:B:C:k:h" opt; do
case "$opt" in
b) background="$OPTARG" ;;
f) text_font="$OPTARG" ;;
s) text_size="$OPTARG" ;;
c) timestamp_color="$OPTARG" ;;
p) timestamp_position="$OPTARG" ;;
o) outline_color="$OPTARG" ;;
w) outline_width="$OPTARG" ;;
q) quality="$OPTARG" ;;
t) tile="$OPTARG" ;;
d) dim_divisor="$OPTARG" ;;
F) header_font="$OPTARG" ;;
S) header_text_size="$OPTARG" ;;
P) header_position="$OPTARG" ;;
C) header_color="$OPTARG" ;;
B) header_background="$OPTARG" ;;
k) skip_sec=$(get_seconds "$OPTARG") ;;
h)
echo "$usage"
exit 0
;;
:)
echo "Option -$OPTARG requires a value" >&2
exit 1
;;
esac
done
shift $((OPTIND-1))
input="$1"
if [ "$input" = "" ]; then
echo "$usage" >&2
exit 1
fi
if [ ! -e "$input" ]; then
echo "No such file: $input" >&2
exit 1
fi
filesize="$(stat -c %s "$input")"
if [ ${filesize:-0} -lt 1 ]; then
echo "Empty video" >&2
exit 2
fi
if [ $filesize -ge 1024 ]; then
if [ $filesize -gt 1073741824 ]; then
suffix="G"
fs_div=1073741824
elif [ $filesize -ge 1048576 ]; then
suffix="M"
fs_div=1048576
else
suffix="K"
fs_div=1024
fi
filesize_info="$filesize B ($(echo "scale=2; $filesize / $fs_div" | bc -l) ${suffix}iB)"
else
filesize_info="$filesize B"
fi
input_basename="$(basename "$input")"
input_filename="${input_basename%.*}"
interval=${2:-50}
interval=$(get_seconds $interval)
if [ $interval -lt 1 ]; then
interval=50
fi
info="$(ffmpeg -i "$input" 2>&1)"
duration="$(echo "$info" | grep Duration | sed -r 's/^\s*Duration: ([:0-9]+).*$/\1/')"
if [ "$duration" = "" -o "$duration" = "00:00:00" ]; then
echo "Empty video" >&2
exit 2
fi
total_seconds="$(date +%s -d "1970-01-01 $duration UTC")"
if [ $total_seconds -lt 1 ]; then
echo "Empty video" >&2
exit 2
elif [ $interval -ge $total_seconds ]; then
echo "The interval must be less than total length of the video: total=$total_seconds interval=$interval" >&1
exit 3
fi
ndigit=${#total_seconds}
dimension="$(echo "$info" | grep '^\s*Stream.*Video' | cut -d , -f 3 | sed -r 's/^\s*([0-9x]+).*$/\1/')"
width="$(echo "$dimension" | cut -d x -f 1)"
height="$(echo "$dimension" | cut -d x -f 2)"
thumb_width=$(( $width / $dim_divisor ))
thumb_height=$(( $height / $dim_divisor ))
thumb_geom="${thumb_width}x${thumb_height}"
temp_dir=$(mktemp -td vidthumbs.XXXXXX)
if [ $? -ne 0 ]; then
echo "Unable to create temporary directory" >&2
exit 4
fi
trap 'quit "$temp_dir"' EXIT SIGINT SIGTERM
printf "Generating thumbnails: " >&2
thumb_index=0
start_sec=$interval
if [ $skip_sec -gt 0 ]; then
start_sec=$(( $start_sec + $skip_sec ))
fi
for (( i = $start_sec; i < $total_seconds ; i += $interval )); do
hh=$(( ($i / 3600) % 24 ))
mm=$(( ($i / 60) % 60 ))
ss=$(( $i % 60 ))
timestamp="$(printf "%02d:%02d:%02d" $hh $mm $ss)"
frame_file="$temp_dir/.frame.jpg"
((thumb_index++))
ffmpeg \
-ss $i \
-i "$input" \
-s $thumb_geom \
"$frame_file" \
-r 1 \
-vframes 1 \
-qscale 1 \
-an \
-vcodec mjpeg > /dev/null 2>&1
thumb_name="$(printf "%s/%0${ndigit}d.jpg" "$temp_dir" "$thumb_index")"
convert \
"$frame_file" \
-gravity "$timestamp_position" \
-font "$text_font" \
-pointsize "$text_size" \
-stroke "$outline_color" \
-strokewidth $outline_width \
-fill none \
-annotate "$timestamp_offset" "$timestamp" \
-stroke none \
-fill "$timestamp_color" \
-annotate "$timestamp_offset" "$timestamp" \
-quality "$quality" \
"$thumb_name"
printf "\rGenerating thumbnails: %d image(s)" "$thumb_index" >&2
done
echo
if [ $thumb_index -lt 2 ]; then
montage_file="$thumb_name"
montage_width="$thumb_width"
else
echo -n "Creating montage: " >&2
montage_file="$temp_dir/.montage.jpg"
montage "$temp_dir"/*.jpg -geometry $thumb_geom -tile "$tile" -background "$background" -quality "$quality" "$montage_file"
montage_dimension="$(identify -format "%wx%h" "$montage_file")"
montage_width="$(echo "$montage_dimension" | cut -d x -f 1)"
printf " %s px @ %d tile(s) created.\n" "$montage_dimension" "$tile" >&2
fi
header="File Name: $input_basename
File Size: $filesize_info
$(echo "$info" | sed -rn 's/^\s+//;/^(Duration|Stream)/p')"
echo "Creating preview header..." >&2
header_file="$temp_dir/.header.gif"
convert \
-size "$(( $montage_width - 10 ))x" \
-background "${header_background:-$background}" \
-font "${header_font:-$text_font}" \
-pointsize "${header_text_size:-$text_size}" \
-fill "${header_color:-black}" \
-stroke none \
-gravity "${header_position:-NorthWest}" \
-bordercolor "${header_background:-$background}" \
-border 5x5 \
caption:"$header" \
"$header_file"
output_filename="${3:-${input_filename}-preview.jpg}"
montage -mode concatenate -tile 1 "$header_file" "$montage_file" -background "$background" -quality "$quality" "$output_filename"
echo "Generated preview: $output_filename" >&2
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment