#!/usr/bin/env bash # # Convert video files to animated WebP. # # Primary path: ffmpeg decodes frames to a temp dir, then img2webp # (from libwebp) assembles them. This works even with ffmpeg builds # that lack the libwebp encoder (e.g. recent Homebrew ffmpeg). # Fallback: ffmpeg's libwebp encoder, if available. # # Usage: to-webp.sh [options] [output] set -e # Defaults FPS=15 WIDTH="" QUALITY=75 LOOP=0 COMPRESSION=6 START="" DURATION="" OVERWRITE=false LOSSLESS=false usage() { cat <<'EOF' Usage: to-webp.sh [options] [output] Options: -f, --fps N Frames per second (default: 15) -w, --width N Output width in px (height auto, keeps aspect) -q, --quality N Quality 0-100 (default: 75) -l, --loop N Loop count, 0=infinite (default: 0) -c, --compression N Compression effort 0-6 (default: 6; higher=smaller) -s, --start TIME Start time (seconds or HH:MM:SS) -d, --duration TIME Max duration (seconds or HH:MM:SS) --lossless Use lossless encoding (larger files) -y, --yes Overwrite output without asking -h, --help Show this help Examples: to-webp.sh demo.mp4 # -> demo.webp (15fps, q75) to-webp.sh -f 24 -w 800 demo.mp4 # 24fps, 800px wide to-webp.sh -q 90 -w 480 clip.mov out.webp # high quality thumbnail to-webp.sh -s 3 -d 5 movie.mp4 clip.webp # 5s clip starting at 3s to-webp.sh -y *.mp4 # batch convert, overwrite EOF exit 0 } have() { command -v "$1" &>/dev/null; } check_deps() { if ! have ffmpeg; then echo "Error: ffmpeg not found. Install ffmpeg." >&2 echo " macOS: brew install ffmpeg" >&2 echo " Ubuntu/Debian: sudo apt-get install ffmpeg" >&2 echo " Fedora: sudo dnf install ffmpeg" >&2 echo " Arch: sudo pacman -S ffmpeg" >&2 return 1 fi if have img2webp; then return 0 fi # Fall back to ffmpeg's libwebp encoder if img2webp isn't installed. if ffmpeg -hide_banner -h encoder=libwebp >/dev/null 2>&1; then return 0 fi echo "Error: need img2webp (from libwebp) or an ffmpeg with libwebp." >&2 echo " macOS: brew install webp" >&2 echo " Ubuntu/Debian: sudo apt-get install webp" >&2 echo " Fedora: sudo dnf install libwebp-tools" >&2 echo " Arch: sudo pacman -S libwebp" >&2 return 1 } get_basename() { echo "${1%.*}"; } # Convert via img2webp: ffmpeg -> PNG frames -> img2webp -> animated webp. convert_via_img2webp() { local input="$1" output="$2" local tmpdir tmpdir=$(mktemp -d -t to-webp.XXXXXX) # shellcheck disable=SC2064 trap "rm -rf '$tmpdir'" RETURN # Build video filter chain local vf="fps=${FPS}" if [[ -n "$WIDTH" ]]; then vf+=",scale=${WIDTH}:-2:flags=lanczos" fi # Extract frames as PNGs local ff=(ffmpeg -hide_banner -loglevel error -y) if [[ -n "$START" ]]; then ff+=(-ss "$START"); fi ff+=(-i "$input") if [[ -n "$DURATION" ]]; then ff+=(-t "$DURATION"); fi ff+=(-vf "$vf" -an "$tmpdir/%06d.png") "${ff[@]}" # Collect frames (globbing avoided in case shopt isn't set) local frames=() while IFS= read -r -d '' f; do frames+=("$f") done < <(find "$tmpdir" -name '*.png' -print0 | sort -z) if [[ ${#frames[@]} -eq 0 ]]; then echo "Error: ffmpeg produced no frames." >&2 return 1 fi # Frame duration in ms (integer); img2webp requires >= 1ms. local frame_ms=$((1000 / FPS)) [[ "$frame_ms" -lt 1 ]] && frame_ms=1 # File-level options first, then per-frame options, then frames. # img2webp defaults to lossless, so -lossy must be set explicitly. local iw=(img2webp -loop "$LOOP") iw+=(-d "$frame_ms" -m "$COMPRESSION") if [[ "$LOSSLESS" == true ]]; then iw+=(-lossless) else iw+=(-lossy -q "$QUALITY") fi iw+=("${frames[@]}" -o "$output") "${iw[@]}" >/dev/null } # Convert via ffmpeg's native libwebp encoder (fallback). convert_via_ffmpeg() { local input="$1" output="$2" local vf="fps=${FPS}" if [[ -n "$WIDTH" ]]; then vf+=",scale=${WIDTH}:-2:flags=lanczos" fi local cmd=(ffmpeg -hide_banner -loglevel error -y) if [[ -n "$START" ]]; then cmd+=(-ss "$START"); fi cmd+=(-i "$input") if [[ -n "$DURATION" ]]; then cmd+=(-t "$DURATION"); fi cmd+=( -vf "$vf" -an -c:v libwebp -loop "$LOOP" -compression_level "$COMPRESSION" -preset picture ) if [[ "$LOSSLESS" == true ]]; then cmd+=(-lossless 1) else cmd+=(-lossless 0 -q:v "$QUALITY") fi cmd+=(-fps_mode vfr "$output") "${cmd[@]}" } convert_file() { local input="$1" output="$2" if [[ ! -f "$input" ]]; then echo "Error: Input file not found: $input" >&2 return 1 fi if [[ -z "$output" ]]; then output="$(get_basename "$input").webp" fi if [[ -f "$output" && "$OVERWRITE" == false ]]; then read -r -p "Output exists: $output. Overwrite? [y/N] " response if [[ ! "$response" =~ ^[Yy]$ ]]; then echo "Skipping: $input" return 0 fi fi local out_dir out_dir=$(dirname "$output") if [[ "$out_dir" != "." && ! -d "$out_dir" ]]; then mkdir -p "$out_dir" fi echo "Converting: $input -> $output" if have img2webp; then convert_via_img2webp "$input" "$output" else convert_via_ffmpeg "$input" "$output" fi echo "Created: $output ($(du -h "$output" | cut -f1))" } main() { local inputs=() output="" while [[ $# -gt 0 ]]; do case "$1" in -f | --fps) FPS="$2"; shift 2 ;; -w | --width) WIDTH="$2"; shift 2 ;; -q | --quality) QUALITY="$2"; shift 2 ;; -l | --loop) LOOP="$2"; shift 2 ;; -c | --compression) COMPRESSION="$2"; shift 2 ;; -s | --start) START="$2"; shift 2 ;; -d | --duration) DURATION="$2"; shift 2 ;; --lossless) LOSSLESS=true; shift ;; -y | --yes) OVERWRITE=true; shift ;; -h | --help) usage ;; -*) echo "Unknown option: $1" >&2 exit 1 ;; *) inputs+=("$1") shift ;; esac done if [[ ${#inputs[@]} -eq 0 ]]; then echo "Error: No input files specified" >&2 usage fi check_deps # If 2 args and second ends in .webp, treat as explicit output. if [[ ${#inputs[@]} -eq 2 ]]; then local last_lc last_lc=$(echo "${inputs[1]}" | tr '[:upper:]' '[:lower:]') if [[ "$last_lc" == *.webp ]]; then output="${inputs[1]}" inputs=("${inputs[0]}") fi fi if [[ ${#inputs[@]} -gt 1 ]]; then for input in "${inputs[@]}"; do convert_file "$input" "" done return 0 fi convert_file "${inputs[0]}" "$output" } main "$@"