Skip to content

Instantly share code, notes, and snippets.

@digiguru
Last active September 12, 2024 21:01
Show Gist options
  • Save digiguru/756f150e88087257a9a287e0d893390c to your computer and use it in GitHub Desktop.
Save digiguru/756f150e88087257a9a287e0d893390c to your computer and use it in GitHub Desktop.
A simple service that will take a .mov and search through every frame looking for differences between that frame and the previous and create a gif with 1 frame for every _changed_ image (visually)
#!/usr/bin/env bash
# Default values
FRAME_RATE=4 # Default frame rate (every nth frame)
SSIM_THRESHOLD=0.98 # Threshold below which frames are considered different
TARGET_WIDTH="" # Target width for frames in the GIF, empty means original width
TARGET_HEIGHT="" # Target height for frames in the GIF, empty means original height
INPUT_FILE="" # Input filename
OUTPUT_GIF="" # Output filename
CLEAN_FRAMES=false # Whether to clean up the frames folder
CACHE_DIR="cache" # Directory to store cache files
LAST_FRAME_DURATION=5 # Default duration (in seconds) for the last frame
PACE=25 # Delay pace for frame delays (in 1/100s)
# Generate a unique cache filename based on the input parameters
generate_cache_file() {
local hash
hash=$(echo "$INPUT_FILE-$FRAME_RATE-$SSIM_THRESHOLD-$TARGET_WIDTH-$TARGET_HEIGHT-$PACE" | md5sum | awk '{print $1}')
echo "$CACHE_DIR/cache_$hash.txt"
}
# Create a directory to hold the frames if it doesn't exist
prepare_frames_directory() {
if [[ ! -d "frames" ]]; then
mkdir -p frames
fi
}
# Generate scale filter based on target width and height
generate_scale_filter() {
local width="$1"
local height="$2"
local scale_filter=""
if [[ -n "$width" ]] && [[ -n "$height" ]]; then
scale_filter="scale=$width:$height"
elif [[ -n "$width" ]]; then
scale_filter="scale=$width:-1"
elif [[ -n "$height" ]]; then
scale_filter="scale=-1:$height"
fi
echo "$scale_filter"
}
# Extract frames from the video file
extract_frames() {
local RESIZE_FILTER
RESIZE_FILTER=$(generate_scale_filter "$TARGET_WIDTH" "$TARGET_HEIGHT")
if [[ -n "$RESIZE_FILTER" ]]; then
RESIZE_FILTER=",$RESIZE_FILTER"
fi
ffmpeg -i "$INPUT_FILE" -vf "select=not(mod(n\,$FRAME_RATE)),setpts=N/FRAME_RATE/TB$RESIZE_FILTER" frames/frame%04d.png
}
# Calculate the delay based on the number of skipped frames
calculate_delay() {
local skipped_frames=$1
local delay=$(( (skipped_frames + 1) * PACE ))
echo $delay
}
# Progress bar to show the frame processing progress
progress_bar() {
local total=$1
local current=$2
local width=50
local percent=$((current * 100 / total))
local count=$((width * current / total))
printf "\r["
for ((i=0; i<count; i++)); do
printf "#"
done
for ((i=count; i<width; i++)); do
printf " "
done
printf "] %d%%" $percent
}
# Main script execution
main() {
if [[ "$1" == "--help" ]]; then
display_help
fi
while [[ "$#" -gt 0 ]]; do
case $1 in
-f|--frame-rate) FRAME_RATE="$2"; shift ;;
-s|--ssim) SSIM_THRESHOLD="$2"; shift ;;
-w|--width) TARGET_WIDTH="$2"; shift ;;
-h|--height) TARGET_HEIGHT="$2"; shift ;;
-i|--input) INPUT_FILE="$2"; shift ;;
-o|--output) OUTPUT_GIF="$2"; shift ;;
-l|--last-frame-duration) LAST_FRAME_DURATION="$2"; shift ;;
-p|--pace) PACE="$2"; shift ;;
-c|--clean-frames) CLEAN_FRAMES=true ;;
--help) display_help ;;
*) echo "Unknown parameter passed: $1"; display_help ;;
esac
shift
done
if [[ -z "$INPUT_FILE" || -z "$OUTPUT_GIF" ]]; then
echo "Both input and output filenames must be provided."
exit 1
fi
# Prepare directories
mkdir -p "$CACHE_DIR"
prepare_frames_directory
cache_file=$(generate_cache_file)
if [[ -f "$cache_file" ]]; then
echo "Cache file found. Using cached data..."
frames_list=()
delays_list=()
while IFS=":" read -r frame status delay; do
if [[ "$status" == "included" ]]; then
frames_list+=("$frame")
delays_list+=("$delay")
fi
done < "$cache_file"
else
extract_frames
frames_list=()
delays_list=()
SKIPPED_FRAMES=0
current_frame=0
total_frames=$(ls frames/frame*.png | wc -l)
if [ "$total_frames" -eq 0 ]; then
echo "Error: No frames found. Please ensure the frames are extracted correctly."
exit 1
fi
# Open cache file for writing
exec 3>"$cache_file"
frame_files=(frames/frame*.png)
frame_count=${#frame_files[@]}
LAST_CONFIRMED_FRAME=""
LAST_CONFIRMED_FRAME_INDEX=0
for index in "${!frame_files[@]}"; do
frame="${frame_files[$index]}"
frame_number=$(basename "$frame" | sed 's/frame\([0-9]*\)\.png/\1/')
# Remove leading zeros from frame_number
frame_number=$((10#$frame_number))
if [[ -z "$LAST_CONFIRMED_FRAME" ]]; then
# First frame
frames_list+=("$frame")
delays_list+=(1) # Initial delay
LAST_CONFIRMED_FRAME="$frame"
LAST_CONFIRMED_FRAME_INDEX=0
SKIPPED_FRAMES=0
echo "$frame:included:1" >&3
else
# Compare with last confirmed frame
ssim=$(ffmpeg -i "$LAST_CONFIRMED_FRAME" -i "$frame" -filter_complex ssim -an -f null - 2>&1 | awk '/SSIM/ {print $5}' | cut -d':' -f2)
if (( $(echo "$ssim < $SSIM_THRESHOLD" | bc -l) )); then
# Frames are different, include current frame
# Calculate delay for the last confirmed frame
delay=$(calculate_delay "$SKIPPED_FRAMES")
# Update delay of last confirmed frame
delays_list[$LAST_CONFIRMED_FRAME_INDEX]=$delay
# Write to cache
echo "${frames_list[$LAST_CONFIRMED_FRAME_INDEX]}:included:$delay" >&3
# Include current frame
frames_list+=("$frame")
delays_list+=(1) # Initial delay, will be updated later if needed
# Update last confirmed frame variables
LAST_CONFIRMED_FRAME="$frame"
LAST_CONFIRMED_FRAME_INDEX=${#frames_list[@]}-1
SKIPPED_FRAMES=0
else
# Frames are similar, skip current frame
SKIPPED_FRAMES=$((SKIPPED_FRAMES + 1))
echo "$frame:skipped:" >&3
fi
fi
current_frame=$((current_frame + 1))
progress_bar "$frame_count" "$current_frame"
done
echo
# After processing all frames, update delay of the last confirmed frame
if [[ -n "$LAST_CONFIRMED_FRAME" ]]; then
delay=$(calculate_delay "$SKIPPED_FRAMES")
delays_list[$LAST_CONFIRMED_FRAME_INDEX]=$((LAST_FRAME_DURATION * 100)) # Set last frame delay
# Write to cache
echo "${frames_list[$LAST_CONFIRMED_FRAME_INDEX]}:included:$delay" >&3
fi
# Close cache file
exec 3>&-
fi
# Generate the GIF
generate_gif
# Clean up frames if requested
if [ "$CLEAN_FRAMES" = true ]; then
echo "Cleaning up frames folder..."
rm -r frames
fi
}
# Generate the GIF with palette optimization
generate_gif() {
echo "Generating color palette..."
local SCALE_FILTER
SCALE_FILTER=$(generate_scale_filter "$TARGET_WIDTH" "$TARGET_HEIGHT")
local FILTER_CHAIN
if [[ -n "$SCALE_FILTER" ]]; then
# Append flags=lanczos to the scale filter
SCALE_FILTER="$SCALE_FILTER:flags=lanczos"
FILTER_CHAIN="fps=10,$SCALE_FILTER,palettegen"
else
# No scaling; do not include flags=lanczos
FILTER_CHAIN="fps=10,palettegen"
fi
ffmpeg -i "$INPUT_FILE" -vf "$FILTER_CHAIN" -y palette.png
cmd_args=()
for i in "${!frames_list[@]}"; do
cmd_args+=("-delay" "${delays_list[$i]}" "${frames_list[$i]}")
done
convert "${cmd_args[@]}" -loop 0 -layers Optimize -coalesce -remap palette.png "$OUTPUT_GIF"
}
# Display help documentation
display_help() {
echo "Usage: $0 [options]"
echo
echo "Options:"
echo " -i, --input Input video file"
echo " -o, --output Output GIF file"
echo " -f, --frame-rate Frame rate for extracting frames (default: 4)"
echo " -s, --ssim SSIM threshold for frame comparison (default: 0.98)"
echo " -w, --width Width for GIF scaling (default: original width)"
echo " -h, --height Height for GIF scaling (default: original height)"
echo " -l, --last-frame-duration Duration (in seconds) for the last frame (default: 5)"
echo " -p, --pace Delay pace for frame delays (default: 25)"
echo " -c, --clean-frames Clean up extracted frames after processing"
echo " --help Display this help and exit"
exit 0
}
progress_bar() {
local total=$1
local current=$2
local width=50
local percent=$((current * 100 / total))
local count=$((width * current / total))
printf "\r["
for ((i=0; i<count; i++)); do
printf "#"
done
for ((i=count; i<width; i++)); do
printf " "
done
printf "] %d%%" $percent
}
main "$@"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment