Created
May 6, 2026 17:25
-
-
Save svemat01/e12f6f94a389241ae82d47a7bd976665 to your computer and use it in GitHub Desktop.
IMG Optimization script
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
| #!/usr/bin/env bash | |
| # img – lightweight helpers for image workflows. | |
| # | |
| # Usage: | |
| # img [--dir path] <command> [files...] | |
| # | |
| # Subcommands: | |
| # webp Convert non-WebP assets to WebP via cwebp. | |
| # pngquant Optimize PNG files with pngquant. | |
| # resize Resize a single image with flexible strategies. | |
| # | |
| # Key ideas: | |
| # - Keep each subcommand self-contained and focused. | |
| # - Share a simple picker (gum filter by default, fzf optional). | |
| # - Never overwrite existing files unless the user explicitly chooses to. | |
| # | |
| # Requirements: | |
| # gum (brew install gum) | |
| # cwebp (brew install webp) for `img webp` | |
| # pngquant (brew install pngquant) for `img pngquant` | |
| # Optional: | |
| # fzf (required selector; brew install fzf) | |
| # identify (ImageMagick; brew install imagemagick) for fzf preview | |
| # | |
| # Environment knobs: | |
| # IMG_DEFAULT_DIR – default search root (defaults to cwd) | |
| # IMG_DEFAULT_QUALITY – default WebP quality (0-100, default 85) | |
| # IMG_PNG_QUALITY – default pngquant quality range (default 65-90) | |
| # IMG_PICKER – "gum" (default) or "fzf" | |
| # IMG_DEBUG – if set, show debug logs | |
| set -euo pipefail | |
| GUM_BIN=${GUM_BIN:-gum} | |
| FZF_BIN=${FZF_BIN:-fzf} | |
| IMG_VERSION="0.3.0" | |
| QUICK_MODE=false | |
| # Trap for cleanup on exit/interrupt | |
| declare -a TEMP_FILES=() | |
| cleanup() { | |
| if [[ ${#TEMP_FILES[@]} -gt 0 ]]; then | |
| for f in "${TEMP_FILES[@]}"; do | |
| rm -f "$f" 2>/dev/null | |
| done | |
| fi | |
| } | |
| trap cleanup EXIT | |
| DEFAULT_SEARCH_ROOT=${IMG_DEFAULT_DIR:-$(pwd)} | |
| DEFAULT_WEBP_QUALITY=${IMG_DEFAULT_QUALITY:-85} | |
| DEFAULT_PNG_QUALITY=${IMG_PNG_QUALITY:-65-90} | |
| GLOBAL_DIR="$DEFAULT_SEARCH_ROOT" | |
| usage() { | |
| cat <<'USAGE' | |
| img – focused helpers for common image chores. | |
| Examples: | |
| img webp # Select images (non-WebP) and convert to WebP | |
| img webp assets/*.png # Convert specific files without the picker | |
| img --quick webp *.png # Quick mode: skip prompts, use defaults | |
| img pngquant # Optimize PNG files via pngquant | |
| img info photo.jpg # Show image metadata | |
| Options: | |
| -d, --dir <path> Base directory for interactive selection (default: cwd or $IMG_DEFAULT_DIR) | |
| -q, --quick Skip interactive prompts, use sensible defaults | |
| --version Show version number | |
| -h, --help Show this message | |
| Subcommands: | |
| webp Convert assets to WebP (cwebp) | |
| pngquant Optimize PNG files (pngquant) | |
| resize Resize a single image (ImageMagick) | |
| info Display image metadata (dimensions, format, size) | |
| Set IMG_PICKER=fzf to use fzf instead of gum filter. Logs route through `gum log`. | |
| USAGE | |
| } | |
| has_cmd() { | |
| command -v "$1" >/dev/null 2>&1 | |
| } | |
| die() { | |
| "$GUM_BIN" log --level error "$1" >&2 | |
| exit 1 | |
| } | |
| log_info() { | |
| "$GUM_BIN" log --level info "$@" | |
| } | |
| log_warn() { | |
| "$GUM_BIN" log --level warn "$@" | |
| } | |
| log_debug() { | |
| if [[ -n "${IMG_DEBUG:-}" ]]; then | |
| "$GUM_BIN" log --level debug "$@" | |
| fi | |
| } | |
| log_progress() { | |
| local current="$1" total="$2" label="$3" | |
| "$GUM_BIN" log --level info "[$current/$total] $label" | |
| } | |
| get_file_size() { | |
| stat -f%z "$1" 2>/dev/null || stat -c%s "$1" 2>/dev/null || echo 0 | |
| } | |
| format_bytes() { | |
| local bytes="$1" | |
| if [[ $bytes -ge 1073741824 ]]; then | |
| awk -v b="$bytes" 'BEGIN{printf "%.2f GB", b/1073741824}' | |
| elif [[ $bytes -ge 1048576 ]]; then | |
| awk -v b="$bytes" 'BEGIN{printf "%.2f MB", b/1048576}' | |
| elif [[ $bytes -ge 1024 ]]; then | |
| awk -v b="$bytes" 'BEGIN{printf "%.2f KB", b/1024}' | |
| else | |
| printf '%d B' "$bytes" | |
| fi | |
| } | |
| print_summary() { | |
| local count="$1" original_size="$2" output_size="$3" start_time="$4" | |
| local end_time elapsed savings_pct | |
| end_time=$(date +%s) | |
| elapsed=$((end_time - start_time)) | |
| if [[ $original_size -gt 0 ]]; then | |
| savings_pct=$(awk -v orig="$original_size" -v out="$output_size" 'BEGIN{printf "%.1f", (1 - out/orig) * 100}') | |
| else | |
| savings_pct="0" | |
| fi | |
| echo "" | |
| "$GUM_BIN" style --border rounded --padding "0 1" --border-foreground 212 \ | |
| "$(printf 'Summary\n───────────────────\nFiles processed: %d\nOriginal size: %s\nOutput size: %s\nSpace saved: %s%%\nTime elapsed: %ds' \ | |
| "$count" "$(format_bytes "$original_size")" "$(format_bytes "$output_size")" "$savings_pct" "$elapsed")" | |
| } | |
| copy_to_clipboard() { | |
| local -a paths=("$@") | |
| if [[ "$QUICK_MODE" == "true" ]]; then | |
| return | |
| fi | |
| local clip_cmd="" | |
| if has_cmd pbcopy; then | |
| clip_cmd="pbcopy" | |
| elif has_cmd xclip; then | |
| clip_cmd="xclip -selection clipboard" | |
| elif has_cmd xsel; then | |
| clip_cmd="xsel --clipboard --input" | |
| else | |
| return | |
| fi | |
| if "$GUM_BIN" confirm "Copy output path(s) to clipboard?"; then | |
| printf '%s\n' "${paths[@]}" | $clip_cmd | |
| log_info "Copied ${#paths[@]} path(s) to clipboard" | |
| fi | |
| } | |
| require_cmd() { | |
| local bin="$1" | |
| local hint="$2" | |
| if ! has_cmd "$bin"; then | |
| die "${bin} is required. ${hint}" | |
| fi | |
| } | |
| tolower() { | |
| printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | |
| } | |
| contains_item() { | |
| local needle="$1" | |
| shift || true | |
| for candidate in "$@"; do | |
| if [[ "$needle" == "$candidate" ]]; then | |
| return 0 | |
| fi | |
| done | |
| return 1 | |
| } | |
| scan_tree() { | |
| local dir="$1" | |
| local tmp | |
| tmp=$(mktemp) | |
| TEMP_FILES+=("$tmp") | |
| "$GUM_BIN" spin --spinner line --title "Scanning ${dir}" -- bash -c ' | |
| set -euo pipefail | |
| dir="$1" | |
| out="$2" | |
| find "$dir" \ | |
| \( -path "*/node_modules" -o -path "*/.git" -o -path "*/.hg" -o -path "*/.svn" -o -path "*/.next" -o -path "*/dist" -o -path "*/build" -o -path "*/.cache" -o -path "*/.turbo" -o -path "*/.venv" -o -path "*/.*" \) -prune -o \ | |
| -type f -print | sort >"$out" | |
| ' _ "$dir" "$tmp" | |
| cat "$tmp" | |
| rm -f "$tmp" | |
| } | |
| list_candidates() { | |
| local dir="$1" | |
| local allowed_csv="${2:-}" | |
| local disallowed_csv="${3:-}" | |
| local -a allowed=() | |
| local -a disallowed=() | |
| if [[ -n "$allowed_csv" ]]; then | |
| IFS=',' read -r -a allowed <<< "$allowed_csv" | |
| fi | |
| if [[ -n "$disallowed_csv" ]]; then | |
| IFS=',' read -r -a disallowed <<< "$disallowed_csv" | |
| fi | |
| while IFS= read -r path; do | |
| [[ -z "$path" ]] && continue | |
| local ext="${path##*.}" | |
| if [[ "$path" == "$ext" ]]; then | |
| continue | |
| fi | |
| ext=$(tolower "$ext") | |
| if [[ ${#allowed[@]} -gt 0 ]] && ! contains_item "$ext" "${allowed[@]}"; then | |
| continue | |
| fi | |
| if [[ ${#disallowed[@]} -gt 0 ]] && contains_item "$ext" "${disallowed[@]}"; then | |
| continue | |
| fi | |
| printf '%s\n' "$path" | |
| done < <(scan_tree "$dir") | |
| } | |
| fzf_select() { | |
| require_cmd "$FZF_BIN" "Install fzf (brew install fzf)." | |
| local placeholder="$1" | |
| local mode="$2" # multi or single | |
| local preview_cmd=$'identify -verbose {} 2>/dev/null | awk \'/Geometry|Format|Filesize/{print $0}\'; file {} 2>/dev/null' | |
| local opts=(--preview "$preview_cmd" --border --prompt "$placeholder > " --scheme=path) | |
| if [[ "$mode" == "multi" ]]; then | |
| opts+=(--multi) | |
| else | |
| opts+=(--no-multi) | |
| fi | |
| "$FZF_BIN" "${opts[@]}" | |
| } | |
| select_assets() { | |
| local dir="$1" | |
| local placeholder="$2" | |
| local allowed="$3" | |
| local disallowed="${4:-}" | |
| local mode="${5:-multi}" | |
| local -a candidates=() | |
| while IFS= read -r path; do | |
| [[ -z "$path" ]] && continue | |
| candidates+=("$path") | |
| done < <(list_candidates "$dir" "$allowed" "$disallowed") | |
| if [[ ${#candidates[@]} -eq 0 ]]; then | |
| die "No matching files found under ${dir}" | |
| fi | |
| log_debug "Selector prepared ${#candidates[@]} candidate(s) from ${dir}" | |
| local selection="" | |
| selection=$(printf '%s\n' "${candidates[@]}" | fzf_select "$placeholder" "$mode") || return 1 | |
| [[ -z "$selection" ]] && return 1 | |
| printf '%s\n' "$selection" | |
| } | |
| next_available_path() { | |
| local desired="$1" | |
| if [[ ! -e "$desired" ]]; then | |
| printf '%s\n' "$desired" | |
| return | |
| fi | |
| local dir base ext stem counter=1 candidate | |
| dir=$(dirname "$desired") | |
| base=$(basename "$desired") | |
| ext="${base##*.}" | |
| stem="${base%.*}" | |
| while true; do | |
| candidate="${dir}/${stem}_${counter}.${ext}" | |
| if [[ ! -e "$candidate" ]]; then | |
| printf '%s\n' "$candidate" | |
| return | |
| fi | |
| counter=$((counter + 1)) | |
| done | |
| } | |
| copy_path_with_suffix() { | |
| local src="$1" | |
| local suffix="$2" | |
| local forced_ext="${3:-}" | |
| local dir base ext stem | |
| dir=$(dirname "$src") | |
| base=$(basename "$src") | |
| ext="${base##*.}" | |
| stem="${base%.*}" | |
| if [[ -n "$forced_ext" ]]; then | |
| ext="$forced_ext" | |
| fi | |
| local desired="${dir}/${stem}${suffix}.${ext}" | |
| next_available_path "$desired" | |
| } | |
| select_or_args() { | |
| local allowed="$1" | |
| local disallowed="$2" | |
| local mode="multi" | |
| if [[ $# -ge 3 ]]; then | |
| case "$3" in | |
| multi|single) | |
| mode="$3" | |
| shift 3 | |
| ;; | |
| *) | |
| shift 2 | |
| ;; | |
| esac | |
| else | |
| shift 2 | |
| fi | |
| local -a provided=("$@") | |
| local -a files=() | |
| if [[ ${#provided[@]} -gt 0 ]]; then | |
| local -a allowed_arr=() | |
| local -a disallowed_arr=() | |
| [[ -n "$allowed" ]] && IFS=',' read -r -a allowed_arr <<< "$allowed" | |
| [[ -n "$disallowed" ]] && IFS=',' read -r -a disallowed_arr <<< "$disallowed" | |
| for path in "${provided[@]}"; do | |
| if [[ ! -f "$path" ]]; then | |
| log_warn "Skipping missing file: $path" | |
| continue | |
| fi | |
| local ext="${path##*.}" | |
| ext=$(tolower "$ext") | |
| if [[ ${#allowed_arr[@]} -gt 0 ]] && ! contains_item "$ext" "${allowed_arr[@]}"; then | |
| log_warn "Skipping unsupported file type: $path" | |
| continue | |
| fi | |
| if [[ ${#disallowed_arr[@]} -gt 0 ]] && contains_item "$ext" "${disallowed_arr[@]}"; then | |
| log_warn "Skipping disallowed file type: $path" | |
| continue | |
| fi | |
| files+=("$path") | |
| done | |
| fi | |
| if [[ "$mode" == "single" && ${#files[@]} -gt 1 ]]; then | |
| log_warn "Multiple files provided; using ${files[0]}" | |
| files=("${files[0]}") | |
| fi | |
| if [[ ${#files[@]} -gt 0 ]]; then | |
| printf '%s\n' "${files[@]}" | |
| return 0 | |
| fi | |
| local selection="" | |
| if ! selection=$(select_assets "$GLOBAL_DIR" "Select files" "$allowed" "$disallowed" "$mode"); then | |
| return 1 | |
| fi | |
| local -a chosen=() | |
| while IFS= read -r line; do | |
| [[ -z "$line" ]] && continue | |
| chosen+=("$line") | |
| done <<< "$selection" | |
| printf '%s\n' "${chosen[@]}" | |
| } | |
| cmd_webp() { | |
| require_cmd cwebp "Install via brew install webp." | |
| local data | |
| if ! data=$(select_or_args "png,jpg,jpeg,bmp,gif,tif,tiff,heic,avif" "webp" "multi" "$@"); then | |
| return 1 | |
| fi | |
| local -a files=() | |
| while IFS= read -r item; do | |
| [[ -z "$item" ]] && continue | |
| files+=("$item") | |
| done <<< "$data" | |
| log_debug "cmd_webp: Parsed ${#files[@]} file(s) from selection" | |
| if [[ ${#files[@]} -eq 0 ]]; then | |
| die "No eligible files selected for WebP conversion." | |
| fi | |
| local quality="$DEFAULT_WEBP_QUALITY" | |
| if [[ "$QUICK_MODE" != "true" ]]; then | |
| quality=$("$GUM_BIN" input \ | |
| --placeholder "$DEFAULT_WEBP_QUALITY" \ | |
| --value "$DEFAULT_WEBP_QUALITY" \ | |
| --header "WebP quality (0-100)\n60-75=small file · 80-90=crisp hero · 100=lossless") | |
| quality=${quality:-$DEFAULT_WEBP_QUALITY} | |
| fi | |
| local custom_dir="" | |
| if [[ "$QUICK_MODE" != "true" ]]; then | |
| local dest_choice | |
| dest_choice=$("$GUM_BIN" choose --header "Where should .webp files be saved? Same dir keeps assets together · Custom dir good for build outputs" \ | |
| "Same directory" \ | |
| "Custom directory") | |
| if [[ "$dest_choice" == "Custom directory" ]]; then | |
| custom_dir=$("$GUM_BIN" input --placeholder "$GLOBAL_DIR/webp" --header "Output directory") | |
| custom_dir=${custom_dir:-"$GLOBAL_DIR/webp"} | |
| mkdir -p "$custom_dir" | |
| log_debug "cmd_webp: Using custom directory: $custom_dir" | |
| else | |
| log_debug "cmd_webp: Using same directory as source files" | |
| fi | |
| else | |
| log_debug "cmd_webp: Quick mode - using same directory as source files" | |
| fi | |
| local -a outputs=() | |
| local total=${#files[@]} | |
| local current=0 | |
| local start_time total_original_size=0 total_output_size=0 | |
| start_time=$(date +%s) | |
| for src in "${files[@]}"; do | |
| current=$((current + 1)) | |
| local base dir target_dir desired out src_size out_size | |
| base=$(basename "$src") | |
| base="${base%.*}" | |
| dir=$(dirname "$src") | |
| target_dir=${custom_dir:-$dir} | |
| mkdir -p "$target_dir" | |
| desired="${target_dir}/${base}.webp" | |
| out=$(next_available_path "$desired") | |
| log_debug "cmd_webp: Converting $src -> $out (quality=$quality, target_dir=$target_dir)" | |
| log_progress "$current" "$total" "Converting $(basename "$src")" | |
| "$GUM_BIN" spin --title "Converting $(basename "$src")" -- cwebp -q "$quality" "$src" -o "$out" | |
| src_size=$(get_file_size "$src") | |
| out_size=$(get_file_size "$out") | |
| total_original_size=$((total_original_size + src_size)) | |
| total_output_size=$((total_output_size + out_size)) | |
| log_info "Saved $out" | |
| outputs+=("$out") | |
| done | |
| print_summary "$total" "$total_original_size" "$total_output_size" "$start_time" | |
| # Offer to delete originals | |
| if [[ "$QUICK_MODE" != "true" && ${#files[@]} -gt 0 ]]; then | |
| if "$GUM_BIN" confirm "Delete ${#files[@]} original file(s)?"; then | |
| for f in "${files[@]}"; do | |
| rm -f "$f" | |
| done | |
| log_info "Deleted ${#files[@]} original file(s)" | |
| fi | |
| fi | |
| # # Offer to copy to clipboard | |
| # copy_to_clipboard "${outputs[@]}" | |
| printf '%s\n' "${outputs[@]}" | |
| } | |
| cmd_pngquant() { | |
| require_cmd pngquant "Install via brew install pngquant." | |
| local data | |
| if ! data=$(select_or_args "png" "" "multi" "$@"); then | |
| return 1 | |
| fi | |
| local -a files=() | |
| while IFS= read -r item; do | |
| [[ -z "$item" ]] && continue | |
| files+=("$item") | |
| done <<< "$data" | |
| if [[ ${#files[@]} -eq 0 ]]; then | |
| die "No PNG files selected." | |
| fi | |
| local quality_range="$DEFAULT_PNG_QUALITY" | |
| local speed="3" | |
| local output_mode="Write copies with suffix (_optimized)" | |
| local suffix="_optimized" | |
| local overwrite=false | |
| if [[ "$QUICK_MODE" != "true" ]]; then | |
| quality_range=$("$GUM_BIN" input \ | |
| --placeholder "$DEFAULT_PNG_QUALITY" \ | |
| --value "$DEFAULT_PNG_QUALITY" \ | |
| --header "pngquant quality range (min-max)\n65-80=smaller, 80-95=safer") | |
| quality_range=${quality_range:-$DEFAULT_PNG_QUALITY} | |
| speed=$("$GUM_BIN" input \ | |
| --placeholder "1-10" \ | |
| --value "3" \ | |
| --header "pngquant speed (1=best quality, 10=fastest)") | |
| speed=${speed:-3} | |
| output_mode=$("$GUM_BIN" choose --header "Output handling\nCopies keep originals · Overwrite saves space if you're sure" \ | |
| "Write copies with suffix (_optimized)" \ | |
| "Overwrite originals") | |
| if [[ "$output_mode" == "Overwrite originals" ]]; then | |
| if "$GUM_BIN" confirm "Overwrite the selected PNG files?"; then | |
| overwrite=true | |
| else | |
| output_mode="Write copies with suffix (_optimized)" | |
| fi | |
| fi | |
| fi | |
| local -a outputs=() | |
| local -a originals_to_delete=() | |
| local total=${#files[@]} | |
| local current=0 | |
| local start_time total_original_size=0 total_output_size=0 | |
| start_time=$(date +%s) | |
| for src in "${files[@]}"; do | |
| current=$((current + 1)) | |
| local dest src_size out_size | |
| if [[ "$output_mode" == "Write copies with suffix (_optimized)" ]]; then | |
| dest=$(copy_path_with_suffix "$src" "$suffix" "png") | |
| originals_to_delete+=("$src") | |
| else | |
| dest="$src" | |
| fi | |
| src_size=$(get_file_size "$src") | |
| total_original_size=$((total_original_size + src_size)) | |
| log_progress "$current" "$total" "Optimizing $(basename "$src")" | |
| "$GUM_BIN" spin --title "Optimizing $(basename "$src")" -- pngquant --quality "$quality_range" --speed "$speed" --strip --force --output "$dest" "$src" | |
| out_size=$(get_file_size "$dest") | |
| total_output_size=$((total_output_size + out_size)) | |
| log_info "Saved $dest" | |
| outputs+=("$dest") | |
| done | |
| print_summary "$total" "$total_original_size" "$total_output_size" "$start_time" | |
| # Offer to delete originals (only if we made copies, not if we overwrote) | |
| if [[ "$QUICK_MODE" != "true" && ${#originals_to_delete[@]} -gt 0 ]]; then | |
| if "$GUM_BIN" confirm "Delete ${#originals_to_delete[@]} original file(s)?"; then | |
| for f in "${originals_to_delete[@]}"; do | |
| rm -f "$f" | |
| done | |
| log_info "Deleted ${#originals_to_delete[@]} original file(s)" | |
| fi | |
| fi | |
| # # Offer to copy to clipboard | |
| # copy_to_clipboard "${outputs[@]}" | |
| printf '%s\n' "${outputs[@]}" | |
| } | |
| cmd_resize() { | |
| require_cmd magick "Install ImageMagick (brew install imagemagick)." | |
| local data | |
| if ! data=$(select_or_args "png,jpg,jpeg,bmp,gif,tif,tiff,heic,avif,webp" "" "single" "$@"); then | |
| return 1 | |
| fi | |
| local -a files=() | |
| while IFS= read -r item; do | |
| [[ -z "$item" ]] && continue | |
| files+=("$item") | |
| done <<< "$data" | |
| local src="${files[0]:-}" | |
| if [[ -z "$src" ]]; then | |
| die "No image selected for resizing." | |
| fi | |
| local meta | |
| meta=$(magick identify -format "%w %h %m" "$src") || die "Unable to inspect $src" | |
| local orig_w orig_h format | |
| read -r orig_w orig_h format <<< "$meta" | |
| local base=$(basename "$src") | |
| local strategy | |
| strategy=$("$GUM_BIN" choose --header "Resize $base (${orig_w}x${orig_h} $format)\nPick how you'd like to transform it." \ | |
| "Scale by multiplier • Quick % shrink/grow" \ | |
| "Common preset width • Hero/device sizes" \ | |
| "Custom width • Keep aspect ratio" \ | |
| "Custom height • Keep aspect ratio" \ | |
| "Exact size • Fit / fill / stretch") | |
| local -a ops=(-auto-orient) | |
| case "$strategy" in | |
| "Scale by multiplier • Quick % shrink/grow") | |
| local mult_choice | |
| mult_choice=$("$GUM_BIN" choose --header "Pick multiplier\n0.25×=moodboard, 0.5×=retina export, 2×=upscale" \ | |
| "0.25× – Quarter size for previews" \ | |
| "0.5× – Half size for retina/base" \ | |
| "0.75× – Slight reduction" \ | |
| "2× – Upscale for retina assets" \ | |
| "Custom multiplier…") | |
| local factor="" | |
| case "$mult_choice" in | |
| "0.25× – Quarter size for previews") factor="25%";; | |
| "0.5× – Half size for retina/base") factor="50%";; | |
| "0.75× – Slight reduction") factor="75%";; | |
| "2× – Upscale for retina assets") factor="200%";; | |
| "Custom multiplier…") | |
| local custom_mult | |
| custom_mult=$("$GUM_BIN" input --placeholder "e.g. 1.3" --header "Enter multiplier (e.g. 0.8, 1.5)") || return 1 | |
| custom_mult=${custom_mult:-1} | |
| factor=$(awk -v m="$custom_mult" 'BEGIN{printf "%.2f%%", m*100}') | |
| ;; | |
| esac | |
| ops+=(-resize "$factor") | |
| ;; | |
| "Common preset width • Hero/device sizes") | |
| local preset | |
| preset=$("$GUM_BIN" choose --header "Choose a width (height scales automatically)" \ | |
| "3840 – 4K / large hero" \ | |
| "2560 – Desktop retina hero" \ | |
| "1920 – HD hero/banner" \ | |
| "1440 – Laptop breakpoint" \ | |
| "1280 – Tablet landscape" \ | |
| "1024 – Small desktop / blog" \ | |
| "720 – Social preview" \ | |
| "Custom width…") | |
| local width_target="" | |
| case "$preset" in | |
| "3840 – 4K / large hero") width_target=3840;; | |
| "2560 – Desktop retina hero") width_target=2560;; | |
| "1920 – HD hero/banner") width_target=1920;; | |
| "1440 – Laptop breakpoint") width_target=1440;; | |
| "1280 – Tablet landscape") width_target=1280;; | |
| "1024 – Small desktop / blog") width_target=1024;; | |
| "720 – Social preview") width_target=720;; | |
| "Custom width…") | |
| width_target=$("$GUM_BIN" input --placeholder "$orig_w" --header "Target width in pixels (aspect preserved)") || return 1 | |
| ;; | |
| esac | |
| ops+=(-resize "${width_target}x") | |
| ;; | |
| "Custom width • Keep aspect ratio") | |
| local width_input | |
| width_input=$("$GUM_BIN" input --placeholder "$orig_w" --header "Enter width in pixels (height scales to match)") || return 1 | |
| ops+=(-resize "${width_input}x") | |
| ;; | |
| "Custom height • Keep aspect ratio") | |
| local height_input | |
| height_input=$("$GUM_BIN" input --placeholder "$orig_h" --header "Enter height in pixels (width scales to match)") || return 1 | |
| ops+=(-resize "x${height_input}") | |
| ;; | |
| "Exact size • Fit / fill / stretch") | |
| local dims | |
| dims=$("$GUM_BIN" input --placeholder "1920x1080" --header "Enter final size as WIDTHxHEIGHT") || return 1 | |
| if [[ ! "$dims" =~ ^[0-9]+x[0-9]+$ ]]; then | |
| die "Invalid dimensions: $dims" | |
| fi | |
| local target_w="${dims%x*}" | |
| local target_h="${dims#*x}" | |
| local fit_choice | |
| fit_choice=$("$GUM_BIN" choose --header "How should ${target_w}x${target_h} be produced?\nFit keeps entire image, Cover crops, Stretch distorts." \ | |
| "Contain (fit inside, may letterbox)" \ | |
| "Cover (fill + crop center)" \ | |
| "Stretch (force exact, can distort)") | |
| case "$fit_choice" in | |
| "Contain (fit inside, may letterbox)") | |
| ops+=(-resize "${target_w}x${target_h}") | |
| ;; | |
| "Cover (fill + crop center)") | |
| ops+=(-resize "${target_w}x${target_h}^" -gravity center -extent "${target_w}x${target_h}") | |
| ;; | |
| "Stretch (force exact, can distort)") | |
| ops+=(-resize "${target_w}x${target_h}!") | |
| ;; | |
| esac | |
| ;; | |
| esac | |
| local destination_mode | |
| destination_mode=$("$GUM_BIN" choose --header "Where should the resized file go?\nCopies keep originals safe; overwrite only when sure." \ | |
| "Copy with suffix (_resized)" \ | |
| "Copy into custom directory" \ | |
| "Overwrite original") | |
| local dest="" | |
| case "$destination_mode" in | |
| "Copy with suffix (_resized)") | |
| local suffix | |
| suffix=$("$GUM_BIN" input --placeholder "_resized" --value "_resized" --header "Suffix to append before extension") || return 1 | |
| suffix=${suffix:-_resized} | |
| dest=$(copy_path_with_suffix "$src" "$suffix") | |
| ;; | |
| "Copy into custom directory") | |
| local custom_dir | |
| custom_dir=$("$GUM_BIN" input --placeholder "$GLOBAL_DIR/resized" --header "Directory for resized copies") || return 1 | |
| custom_dir=${custom_dir:-"$GLOBAL_DIR/resized"} | |
| mkdir -p "$custom_dir" | |
| dest=$(next_available_path "$custom_dir/$(basename "$src")") | |
| ;; | |
| "Overwrite original") | |
| if "$GUM_BIN" confirm "Really overwrite $base?"; then | |
| dest="$src" | |
| else | |
| dest=$(copy_path_with_suffix "$src" "_resized") | |
| fi | |
| ;; | |
| esac | |
| "$GUM_BIN" spin --title "Resizing $base" -- magick "$src" "${ops[@]}" "$dest" | |
| log_info "Saved $dest" | |
| # # Offer to copy to clipboard | |
| # copy_to_clipboard "$dest" | |
| printf '%s\n' "$dest" | |
| } | |
| cmd_info() { | |
| require_cmd magick "Install ImageMagick (brew install imagemagick)." | |
| local data | |
| if ! data=$(select_or_args "png,jpg,jpeg,bmp,gif,tif,tiff,heic,avif,webp,svg" "" "single" "$@"); then | |
| return 1 | |
| fi | |
| local -a files=() | |
| while IFS= read -r item; do | |
| [[ -z "$item" ]] && continue | |
| files+=("$item") | |
| done <<< "$data" | |
| local src="${files[0]:-}" | |
| if [[ -z "$src" ]]; then | |
| die "No image selected." | |
| fi | |
| local meta size_bytes size_human | |
| meta=$(magick identify -format "Format: %m\nDimensions: %wx%h\nColor: %[colorspace]\nDepth: %z-bit\nType: %[type]" "$src" 2>/dev/null) || die "Unable to inspect $src" | |
| size_bytes=$(get_file_size "$src") | |
| size_human=$(format_bytes "$size_bytes") | |
| local filename | |
| filename=$(basename "$src") | |
| echo "" | |
| "$GUM_BIN" style --border rounded --padding "0 1" --border-foreground 39 \ | |
| "$(printf '%s\n───────────────────\n%s\nFile size: %s\nPath: %s' "$filename" "$meta" "$size_human" "$src")" | |
| } | |
| main() { | |
| require_cmd "$GUM_BIN" "Install gum (brew install gum)." | |
| local args=("$@") | |
| local idx=0 | |
| while [[ $idx -lt ${#args[@]} ]]; do | |
| case "${args[$idx]}" in | |
| -d|--dir) | |
| (( idx + 1 < ${#args[@]} )) || die "--dir requires a value" | |
| GLOBAL_DIR="${args[$((idx + 1))]}" | |
| idx=$((idx + 2)) | |
| ;; | |
| -q|--quick) | |
| QUICK_MODE=true | |
| idx=$((idx + 1)) | |
| ;; | |
| --version) | |
| echo "$IMG_VERSION" | |
| return 0 | |
| ;; | |
| -h|--help) | |
| usage | |
| return 0 | |
| ;; | |
| --) | |
| idx=$((idx + 1)) | |
| break | |
| ;; | |
| *) | |
| break | |
| ;; | |
| esac | |
| done | |
| local -a remaining=() | |
| while [[ $idx -lt ${#args[@]} ]]; do | |
| remaining+=("${args[$idx]}") | |
| idx=$((idx + 1)) | |
| done | |
| local subcommand="" | |
| if [[ ${#remaining[@]} -gt 0 ]]; then | |
| subcommand="${remaining[0]}" | |
| if [[ ${#remaining[@]} -gt 1 ]]; then | |
| remaining=("${remaining[@]:1}") | |
| else | |
| remaining=() | |
| fi | |
| else | |
| usage | |
| return 0 | |
| fi | |
| case "$subcommand" in | |
| webp) | |
| if [[ ${#remaining[@]} -gt 0 ]]; then | |
| cmd_webp "${remaining[@]}" | |
| else | |
| cmd_webp | |
| fi | |
| ;; | |
| pngquant) | |
| if [[ ${#remaining[@]} -gt 0 ]]; then | |
| cmd_pngquant "${remaining[@]}" | |
| else | |
| cmd_pngquant | |
| fi | |
| ;; | |
| resize) | |
| if [[ ${#remaining[@]} -gt 0 ]]; then | |
| cmd_resize "${remaining[@]}" | |
| else | |
| cmd_resize | |
| fi | |
| ;; | |
| info) | |
| if [[ ${#remaining[@]} -gt 0 ]]; then | |
| cmd_info "${remaining[@]}" | |
| else | |
| cmd_info | |
| fi | |
| ;; | |
| help) | |
| usage | |
| ;; | |
| *) | |
| usage | |
| die "Unknown command: $subcommand" | |
| ;; | |
| esac | |
| } | |
| main "$@" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment