Created
March 3, 2026 01:53
-
-
Save evoactivity/2b8805877898c3683f4768cea2a93615 to your computer and use it in GitHub Desktop.
Animated WebP to MP4
This file contains hidden or 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
| #!/bin/bash | |
| # ─── ANSI color codes ──────────────────────────────────────── | |
| RED='\033[0;31m' | |
| GREEN='\033[0;32m' | |
| YELLOW='\033[0;33m' | |
| BLUE='\033[0;34m' | |
| MAGENTA='\033[0;35m' | |
| CYAN='\033[0;36m' | |
| BOLD='\033[1m' | |
| DIM='\033[2m' | |
| RESET='\033[0m' | |
| SEP="${DIM}──────────────────────────────────────────────────${RESET}" | |
| BOX_INNER=38 | |
| # ─── Defaults ───────────────────────────────────────────────── | |
| original_action="move" # move | none | delete | |
| move_dir="" | |
| move_dir_set="no" | |
| single_file="no" | |
| output_name="" | |
| # ─── Cleanup tracking ──────────────────────────────────────── | |
| current_tmpdir="" | |
| current_output="" | |
| interrupted="no" | |
| # ─── Counters ───────────────────────────────────────────────── | |
| total=0 | |
| processed=0 | |
| animated=0 | |
| converted=0 | |
| skipped=0 | |
| failed=0 | |
| # ─── Helper: summary row with dynamic padding ──────────────── | |
| print_summary_row() { | |
| local label_color="$1" | |
| local label="$2" | |
| local value="$3" | |
| local label_width=22 | |
| local value_len=${#value} | |
| local trailing=$((BOX_INNER - 2 - label_width - value_len)) | |
| printf -v lbl "%-${label_width}s" "$label" | |
| printf -v pad "%${trailing}s" "" | |
| echo -e "${BOLD}${MAGENTA} ║${RESET} ${label_color}${lbl}${RESET}${BOLD}${value}${RESET}${pad}${BOLD}${MAGENTA}║${RESET}" | |
| } | |
| # ─── Print summary ──────────────────────────────────────────── | |
| print_summary() { | |
| echo "" | |
| echo -e "$SEP" | |
| echo -e "${BOLD}${MAGENTA} ╔══════════════════════════════════════╗${RESET}" | |
| if [ "$interrupted" = "yes" ]; then | |
| echo -e "${BOLD}${MAGENTA} ║${RESET} ${RED}${BOLD}Interrupted -- Summary${RESET} ${BOLD}${MAGENTA}║${RESET}" | |
| else | |
| echo -e "${BOLD}${MAGENTA} ║ Summary ║${RESET}" | |
| fi | |
| echo -e "${BOLD}${MAGENTA} ╠══════════════════════════════════════╣${RESET}" | |
| print_summary_row "$BLUE" "Total WebP files:" "$total" | |
| print_summary_row "$DIM" "Processed:" "$processed" | |
| print_summary_row "$YELLOW" "Animated:" "$animated" | |
| print_summary_row "$GREEN" "Converted:" "$converted" | |
| print_summary_row "$DIM" "Skipped (static):" "$skipped" | |
| if [ "$failed" -gt 0 ]; then | |
| print_summary_row "$RED" "Failed:" "$failed" | |
| fi | |
| if [ "$interrupted" = "yes" ]; then | |
| local remaining=$((total - processed)) | |
| print_summary_row "$RED" "Not processed:" "$remaining" | |
| fi | |
| echo -e "${BOLD}${MAGENTA} ╚══════════════════════════════════════╝${RESET}" | |
| echo "" | |
| } | |
| # ─── Signal handler ─────────────────────────────────────────── | |
| cleanup() { | |
| interrupted="yes" | |
| echo "" | |
| echo -e " ${RED}${BOLD}!! Signal received -- cleaning up...${RESET}" | |
| if [ -n "$current_tmpdir" ] && [ -d "$current_tmpdir" ]; then | |
| rm -rf "$current_tmpdir" | |
| echo -e " ${DIM} Removed temporary frames: ${current_tmpdir}${RESET}" | |
| fi | |
| if [ -n "$current_output" ] && [ -f "$current_output" ]; then | |
| rm -f "$current_output" | |
| echo -e " ${DIM} Removed partial output: $(basename "$current_output")${RESET}" | |
| fi | |
| print_summary | |
| exit 130 | |
| } | |
| trap cleanup SIGINT SIGTERM SIGHUP | |
| # ─── Usage / Help ───────────────────────────────────────────── | |
| show_help() { | |
| echo "" | |
| echo -e "${BOLD}${MAGENTA} Animated WebP -> MP4 Converter${RESET}" | |
| echo "" | |
| echo -e " ${BOLD}Usage:${RESET} $(basename "$0") [options] [input] [output]" | |
| echo "" | |
| echo -e " ${BOLD}Description:${RESET}" | |
| echo -e " Converts animated WebP files to MP4 using ImageMagick and ffmpeg." | |
| echo -e " Dimensions are adjusted to be divisible by 2 as required by h264." | |
| echo "" | |
| echo -e " ${BOLD}Positional arguments:${RESET}" | |
| echo -e " ${CYAN}input${RESET} Directory to scan for .webp files, or a single .webp file" | |
| echo -e " (default: current directory)" | |
| echo -e " ${CYAN}output${RESET} Output directory for MP4 files (created if needed)" | |
| echo -e " When input is a single file, this can be an .mp4 filename" | |
| echo -e " (default: same location as input)" | |
| echo "" | |
| echo -e " ${BOLD}Options:${RESET}" | |
| echo -e " ${CYAN}-h${RESET}, ${CYAN}--help${RESET} Show this help message and exit" | |
| echo -e " ${CYAN}--move-to <dir>${RESET} Move originals to <dir> after conversion" | |
| echo -e " (default: ${CYAN}original-webp/${RESET} beside input)" | |
| echo -e " ${CYAN}--no-move${RESET} Leave original files in place" | |
| echo -e " ${CYAN}--delete${RESET} Delete original files after successful conversion" | |
| echo "" | |
| echo -e " ${BOLD}Examples:${RESET}" | |
| echo -e " $(basename "$0") Current dir, default settings" | |
| echo -e " $(basename "$0") /path/to/webps Scan a directory" | |
| echo -e " $(basename "$0") /path/to/webps ./output Dir input, MP4s to ./output" | |
| echo -e " $(basename "$0") image.webp Convert one file" | |
| echo -e " $(basename "$0") image.webp clip.mp4 Convert with custom output name" | |
| echo -e " $(basename "$0") image.webp ./videos/ Single file to output directory" | |
| echo -e " $(basename "$0") --delete ./input Delete originals after conversion" | |
| echo -e " $(basename "$0") --no-move . Leave originals in place" | |
| echo -e " $(basename "$0") --move-to /backup ./input ./output Custom move dir, in/out dirs" | |
| echo "" | |
| echo -e " ${BOLD}Required tools:${RESET}" | |
| echo -e " ${DIM}webpmux${RESET} libwebp / webp package" | |
| echo -e " ${DIM}convert${RESET} ImageMagick" | |
| echo -e " ${DIM}identify${RESET} ImageMagick" | |
| echo -e " ${DIM}ffmpeg${RESET} FFmpeg (with libx264)" | |
| echo -e " ${DIM}awk${RESET} Usually pre-installed" | |
| echo "" | |
| exit 0 | |
| } | |
| # ─── Parse arguments ────────────────────────────────────────── | |
| positional=() | |
| while [ $# -gt 0 ]; do | |
| case "$1" in | |
| -h|--help) | |
| show_help | |
| ;; | |
| --move-to) | |
| if [ -z "$2" ] || [[ "$2" == -* ]]; then | |
| echo -e "${RED}[error] --move-to requires a directory argument${RESET}" | |
| exit 1 | |
| fi | |
| original_action="move" | |
| move_dir="$2" | |
| move_dir_set="yes" | |
| shift 2 | |
| ;; | |
| --no-move) | |
| original_action="none" | |
| shift | |
| ;; | |
| --delete) | |
| original_action="delete" | |
| shift | |
| ;; | |
| -*) | |
| echo -e "${RED}[error] Unknown option: ${1}${RESET}" | |
| echo -e " Run ${CYAN}$(basename "$0") --help${RESET} for usage info." | |
| exit 1 | |
| ;; | |
| *) | |
| positional+=("$1") | |
| shift | |
| ;; | |
| esac | |
| done | |
| if [ ${#positional[@]} -ge 3 ]; then | |
| echo -e "${RED}[error] Too many positional arguments (max 2)${RESET}" | |
| echo -e " Run ${CYAN}$(basename "$0") --help${RESET} for usage info." | |
| exit 1 | |
| fi | |
| input_path="${positional[0]:-.}" | |
| output_path="${positional[1]:-}" | |
| # ─── Determine mode: single file or directory ───────────────── | |
| if [ -f "$input_path" ]; then | |
| single_file="yes" | |
| input_dir=$(cd "$(dirname "$input_path")" && pwd) | |
| input_file=$(basename "$input_path") | |
| if [ -n "$output_path" ]; then | |
| case "$output_path" in | |
| *.mp4) | |
| output_name=$(basename "$output_path") | |
| output_dir=$(dirname "$output_path") | |
| ;; | |
| *) | |
| output_name="" | |
| output_dir="$output_path" | |
| ;; | |
| esac | |
| else | |
| output_name="" | |
| output_dir="$input_dir" | |
| fi | |
| elif [ -d "$input_path" ]; then | |
| single_file="no" | |
| input_dir=$(cd "$input_path" && pwd) | |
| if [ -n "$output_path" ]; then | |
| if [[ "$output_path" == *.mp4 ]]; then | |
| echo -e "${RED}[error] Cannot use an .mp4 filename as output when input is a directory${RESET}" | |
| echo -e " Use an output ${CYAN}directory${RESET} instead, or pass a single .webp file as input." | |
| exit 1 | |
| fi | |
| output_dir="$output_path" | |
| else | |
| output_dir="$input_dir" | |
| fi | |
| else | |
| echo -e "${RED}[error] Input not found: ${input_path}${RESET}" | |
| exit 1 | |
| fi | |
| mkdir -p "$output_dir" | |
| if [ "$original_action" = "move" ] && [ "$move_dir_set" = "no" ]; then | |
| move_dir="$input_dir/original-webp" | |
| fi | |
| if [ "$original_action" = "move" ]; then | |
| mkdir -p "$move_dir" | |
| fi | |
| # ─── Dependency check ───────────────────────────────────────── | |
| required_tools=("webpmux" "convert" "identify" "ffmpeg" "awk") | |
| missing=() | |
| for tool in "${required_tools[@]}"; do | |
| if ! command -v "$tool" > /dev/null 2>&1; then | |
| missing+=("$tool") | |
| fi | |
| done | |
| if [ ${#missing[@]} -gt 0 ]; then | |
| echo "" | |
| echo -e "${RED}${BOLD}[error] Missing required tool(s):${RESET}" | |
| echo "" | |
| for tool in "${missing[@]}"; do | |
| case "$tool" in | |
| webpmux) pkg="libwebp-tools / webp" ;; | |
| convert|identify) pkg="ImageMagick" ;; | |
| ffmpeg) pkg="FFmpeg (with libx264)" ;; | |
| awk) pkg="awk (usually pre-installed)" ;; | |
| *) pkg="unknown" ;; | |
| esac | |
| echo -e " ${RED}*${RESET} ${BOLD}${tool}${RESET} ${DIM}-- install via: ${pkg}${RESET}" | |
| done | |
| echo "" | |
| echo -e " Install the missing tools and try again." | |
| echo "" | |
| exit 1 | |
| fi | |
| echo -e "${GREEN}${DIM}[ok] All required tools found${RESET}" | |
| # ─── Collect files ──────────────────────────────────────────── | |
| files_to_process=() | |
| if [ "$single_file" = "yes" ]; then | |
| files_to_process=("$input_dir/$input_file") | |
| else | |
| for f in "$input_dir"/*.webp; do | |
| [ -e "$f" ] || continue | |
| files_to_process+=("$f") | |
| done | |
| fi | |
| total=${#files_to_process[@]} | |
| if [ "$total" -eq 0 ]; then | |
| echo -e "${YELLOW}[!] No .webp files found in: ${input_dir}${RESET}" | |
| exit 0 | |
| fi | |
| # ─── Display header ────────────────────────────────────────── | |
| echo "" | |
| echo -e "${BOLD}${MAGENTA} ╔══════════════════════════════════════╗${RESET}" | |
| echo -e "${BOLD}${MAGENTA} ║ Animated WebP -> MP4 Converter ║${RESET}" | |
| echo -e "${BOLD}${MAGENTA} ╚══════════════════════════════════════╝${RESET}" | |
| echo "" | |
| echo -e " ${DIM}Input: ${input_dir}${RESET}" | |
| echo -e " ${DIM}Output: ${output_dir}${RESET}" | |
| echo -e " ${DIM}Files: ${total} .webp file(s) found${RESET}" | |
| case "$original_action" in | |
| move) echo -e " ${DIM}Originals: move to ${move_dir}${RESET}" ;; | |
| delete) echo -e " ${DIM}Originals: delete after conversion${RESET}" ;; | |
| none) echo -e " ${DIM}Originals: leave in place${RESET}" ;; | |
| esac | |
| echo "" | |
| # ─── Process files ──────────────────────────────────────────── | |
| for filepath in "${files_to_process[@]}"; do | |
| file=$(basename "$filepath") | |
| processed=$((processed + 1)) | |
| echo -e "$SEP" | |
| echo -e "${BOLD}${BLUE}> [${processed}/${total}] Processing:${RESET} ${CYAN}${file}${RESET}" | |
| echo -e "$SEP" | |
| if webpmux -info "$filepath" 2>/dev/null | grep -qi "animation"; then | |
| animated=$((animated + 1)) | |
| echo -e " ${YELLOW}[animated] Detected animated image${RESET}" | |
| current_tmpdir=$(mktemp -d) | |
| width=$(identify -format "%w" "${filepath}[0]") | |
| height=$(identify -format "%h" "${filepath}[0]") | |
| echo -e " ${DIM}[info] Original dimensions: ${width}x${height}${RESET}" | |
| new_width=$((width - (width % 2))) | |
| new_height=$((height - (height % 2))) | |
| need_crop="no" | |
| if [[ "$new_width" -ne "$width" ]]; then | |
| need_crop="yes" | |
| fi | |
| if [[ "$new_height" -ne "$height" ]]; then | |
| need_crop="yes" | |
| fi | |
| extract_ok="no" | |
| if [ "$need_crop" = "yes" ]; then | |
| echo -e " ${YELLOW}[crop] Adjusting to ${new_width}x${new_height} (h264 requires even dimensions)${RESET}" | |
| if convert "$filepath" -coalesce -crop "${new_width}x${new_height}+0+0" +repage "$current_tmpdir/frame_%04d.png"; then | |
| extract_ok="yes" | |
| fi | |
| else | |
| if convert "$filepath" -coalesce "$current_tmpdir/frame_%04d.png"; then | |
| extract_ok="yes" | |
| fi | |
| fi | |
| if [ "$extract_ok" = "no" ]; then | |
| echo -e " ${RED}[error] Failed to extract frames${RESET}" | |
| failed=$((failed + 1)) | |
| rm -rf "$current_tmpdir" | |
| current_tmpdir="" | |
| echo "" | |
| continue | |
| fi | |
| frames=("$current_tmpdir"/frame_*.png) | |
| frame_count=${#frames[@]} | |
| if [ "$frame_count" -eq 0 ]; then | |
| echo -e " ${RED}[error] No frames produced${RESET}" | |
| failed=$((failed + 1)) | |
| rm -rf "$current_tmpdir" | |
| current_tmpdir="" | |
| echo "" | |
| continue | |
| fi | |
| echo -e " ${DIM}[info] Extracted ${frame_count} frames${RESET}" | |
| delay=$(identify -format "%T" "${filepath}[0]") | |
| if [ -z "$delay" ]; then | |
| fps=10 | |
| elif [ "$delay" -eq 0 ] 2>/dev/null; then | |
| fps=10 | |
| else | |
| fps=$(awk "BEGIN {printf \"%.2f\", 100 / $delay}") | |
| fi | |
| echo -e " ${DIM}[info] Frame rate: ${fps} fps${RESET}" | |
| if [ "$single_file" = "yes" ] && [ -n "$output_name" ]; then | |
| current_output="$output_dir/$output_name" | |
| else | |
| current_output="$output_dir/${file%.*}.mp4" | |
| fi | |
| echo -e " ${DIM}[encode] Encoding with ffmpeg...${RESET}" | |
| if ffmpeg -y -framerate "$fps" -i "$current_tmpdir/frame_%04d.png" -c:v libx264 -pix_fmt yuv420p -movflags +faststart "$current_output" 2>/dev/null; then | |
| converted=$((converted + 1)) | |
| echo -e " ${GREEN}[done] Converted: $(basename "$current_output")${RESET}" | |
| case "$original_action" in | |
| move) | |
| mv "$filepath" "$move_dir/" | |
| echo -e " ${DIM}[move] Original moved to ${move_dir}/${RESET}" | |
| ;; | |
| delete) | |
| rm "$filepath" | |
| echo -e " ${DIM}[delete] Original deleted${RESET}" | |
| ;; | |
| none) | |
| echo -e " ${DIM}[keep] Original left in place${RESET}" | |
| ;; | |
| esac | |
| else | |
| echo -e " ${RED}[error] ffmpeg encoding failed${RESET}" | |
| failed=$((failed + 1)) | |
| if [ -f "$current_output" ]; then | |
| rm -f "$current_output" | |
| fi | |
| fi | |
| rm -rf "$current_tmpdir" | |
| current_tmpdir="" | |
| current_output="" | |
| else | |
| skipped=$((skipped + 1)) | |
| echo -e " ${DIM}[skip] Not animated -- skipped${RESET}" | |
| fi | |
| echo "" | |
| done | |
| print_summary |
Author
evoactivity
commented
Mar 3, 2026
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment