Last active
September 12, 2024 21:01
-
-
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)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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