Created
May 19, 2026 14:52
-
-
Save mumblequatch/aa46494a47dead91f5d13fee2afd0283 to your computer and use it in GitHub Desktop.
image_optimize_km.sh — Keyboard Maestro batch image optimizer (magick + pngquant) with a native one-shot NSAlert dialog
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 | |
| # Image optimizer — Keyboard Maestro driver | |
| # | |
| # Thin wrapper around ImageMagick (+ pngquant for PNG output). Wire to a KM | |
| # hotkey via a single "Execute Shell Script" action: | |
| # ~/Scripts/image_optimize_km.sh | |
| # (Set the action to "Ignore Results".) | |
| # | |
| # Flow: | |
| # 1. Reads Finder selection (filtered to image files). Terminal args win if | |
| # invoked directly with file paths. | |
| # 2. Prompts for: max longest-edge, output format, quality, destination. | |
| # 3. Shows confirm dialog with source total + chosen settings. | |
| # 4. Opens a Terminal window with per-file progress and size deltas. | |
| # 5. Final notification + reveals last output folder. | |
| # | |
| # Supports: jpg/jpeg, png, gif, bmp, tiff/tif, heic/heif, avif, webp, psd, | |
| # tga, ico — anything ImageMagick reads. | |
| export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH" | |
| # --- Sanity check --------------------------------------------------- | |
| if ! command -v magick >/dev/null 2>&1; then | |
| osascript -e 'display notification "ImageMagick not found. brew install imagemagick" with title "Image Optimizer" sound name "Basso"' | |
| exit 1 | |
| fi | |
| # --- Gather input files --------------------------------------------- | |
| IMG_EXT_RE='\.(jpe?g|png|gif|bmp|tiff?|heic|heif|avif|webp|psd|tga|ico)$' | |
| INPUT_FILES=() | |
| if [ $# -gt 0 ]; then | |
| for f in "$@"; do INPUT_FILES+=( "$f" ); done | |
| else | |
| SELECTION=$(osascript <<'AS' | |
| tell application "Finder" | |
| set sel to selection as alias list | |
| set out to "" | |
| repeat with i in sel | |
| set out to out & POSIX path of i & linefeed | |
| end repeat | |
| return out | |
| end tell | |
| AS | |
| ) | |
| while IFS= read -r line; do | |
| [ -z "$line" ] && continue | |
| if echo "$line" | grep -qiE "$IMG_EXT_RE"; then | |
| INPUT_FILES+=( "$line" ) | |
| fi | |
| done <<< "$SELECTION" | |
| fi | |
| N_FILES=${#INPUT_FILES[@]} | |
| if [ "$N_FILES" -eq 0 ]; then | |
| osascript -e 'display notification "Select image file(s) in Finder first." with title "Image Optimizer" sound name "Basso"' | |
| exit 1 | |
| fi | |
| # --- Tally source size ---------------------------------------------- | |
| TOTAL_BYTES=0 | |
| for f in "${INPUT_FILES[@]}"; do | |
| [ -f "$f" ] || continue | |
| SZ=$(stat -f %z "$f" 2>/dev/null || echo 0) | |
| TOTAL_BYTES=$((TOTAL_BYTES + SZ)) | |
| done | |
| TOTAL_MB=$(awk -v b="$TOTAL_BYTES" 'BEGIN{printf "%.1f", b/1048576}') | |
| # --- Single combined dialog (NSAlert + accessoryView via JXA) ------- | |
| # Native macOS Cocoa dialog with all four controls inline. | |
| DIALOG_OUT=$( | |
| N_FILES_VAR="$N_FILES" TOTAL_MB_VAR="$TOTAL_MB" \ | |
| osascript -l JavaScript <<'JXA' | |
| ObjC.import('Cocoa') | |
| function run() { | |
| const env = $.NSProcessInfo.processInfo.environment | |
| const nFiles = env.objectForKey('N_FILES_VAR').js | |
| const totalMb = env.objectForKey('TOTAL_MB_VAR').js | |
| const W = 380, H = 175 | |
| const view = $.NSView.alloc.initWithFrame($.NSMakeRect(0, 0, W, H)) | |
| function addLabel(text, x, y, w) { | |
| const lbl = $.NSTextField.alloc.initWithFrame($.NSMakeRect(x, y, w, 20)) | |
| lbl.stringValue = text | |
| lbl.editable = false | |
| lbl.bordered = false | |
| lbl.drawsBackground = false | |
| lbl.selectable = false | |
| view.addSubview(lbl) | |
| return lbl | |
| } | |
| function addTextField(value, x, y, w) { | |
| const tf = $.NSTextField.alloc.initWithFrame($.NSMakeRect(x, y, w, 22)) | |
| tf.stringValue = value | |
| view.addSubview(tf) | |
| return tf | |
| } | |
| function addPopup(items, x, y, w, defaultIdx) { | |
| const pop = $.NSPopUpButton.alloc.initWithFramePullsDown($.NSMakeRect(x, y, w, 26), false) | |
| items.forEach(it => pop.addItemWithTitle(it)) | |
| if (defaultIdx >= 0 && defaultIdx < items.length) pop.selectItemAtIndex(defaultIdx) | |
| view.addSubview(pop) | |
| return pop | |
| } | |
| const LBL_W = 105, FLD_X = 115, FLD_W = 255, ROW_H = 34 | |
| let y = H - 32 | |
| addLabel("Max dimension:", 0, y, LBL_W) | |
| const sizePop = addPopup( | |
| ["Keep original size", "2400", "1600", "1200", "1000", "800", "Custom…"], | |
| FLD_X, y - 4, FLD_W, 0) | |
| y -= ROW_H | |
| addLabel("Format:", 0, y, LBL_W) | |
| const formatPop = addPopup( | |
| ["Keep original", "WebP", "JPEG", "PNG"], | |
| FLD_X, y - 4, FLD_W, 2) | |
| y -= ROW_H | |
| addLabel("Quality (1-100):", 0, y, LBL_W) | |
| const qualityTF = addTextField("85", FLD_X, y, FLD_W) | |
| y -= ROW_H | |
| addLabel("Destination:", 0, y, LBL_W) | |
| const destPop = addPopup( | |
| ["Save alongside original (add _opt suffix)", | |
| "Save into 'optimized' subfolder", | |
| "Replace original (keep .bak backup)"], | |
| FLD_X, y - 4, FLD_W, 0) | |
| const alert = $.NSAlert.alloc.init | |
| alert.messageText = "Image Optimizer" | |
| alert.informativeText = nFiles + " file(s) · " + totalMb + " MB total" | |
| alert.accessoryView = view | |
| alert.addButtonWithTitle("Run") | |
| alert.addButtonWithTitle("Cancel") | |
| // Bring the dialog to the front without pinning it on top. | |
| // Accessory policy gives osascript a key window without a Dock icon; | |
| // activateIgnoringOtherApps puts it in front initially. The alert window | |
| // is a normal-level window, so other apps can still come forward by click. | |
| const app = $.NSApplication.sharedApplication | |
| app.setActivationPolicy($.NSApplicationActivationPolicyAccessory) | |
| app.activateIgnoringOtherApps(true) | |
| const r = alert.runModal | |
| if (r != 1000) return "CANCEL" | |
| return [ | |
| ObjC.unwrap(sizePop.titleOfSelectedItem), | |
| ObjC.unwrap(formatPop.titleOfSelectedItem), | |
| ObjC.unwrap(qualityTF.stringValue), | |
| ObjC.unwrap(destPop.titleOfSelectedItem), | |
| ].join("\t") | |
| } | |
| JXA | |
| ) | |
| [ "$DIALOG_OUT" = "CANCEL" ] && exit 0 | |
| # Parse the tab-delimited result | |
| IFS=$'\t' read -r MAX_DIM FORMAT QUALITY DEST <<< "$DIALOG_OUT" | |
| # Handle "Custom…" → follow-up dialog asking for the long-edge px value | |
| if [ "$MAX_DIM" = "Custom…" ]; then | |
| MAX_DIM=$(osascript <<'EOF' | |
| set theResp to display dialog "Longest edge (px):" default answer "1200" with title "Image Optimizer" buttons {"Cancel","OK"} default button "OK" | |
| if button returned of theResp is "Cancel" then return "CANCEL" | |
| return text returned of theResp | |
| EOF | |
| ) | |
| [ "$MAX_DIM" = "CANCEL" ] && exit 0 | |
| fi | |
| # Normalize size: "Keep original size" → 0 (no resize); otherwise extract digits | |
| case "$MAX_DIM" in | |
| Keep*|keep*|"") MAX_DIM="0" ;; | |
| esac | |
| MAX_DIM=$(echo "$MAX_DIM" | tr -cd '0-9') | |
| [ -z "$MAX_DIM" ] && MAX_DIM="0" | |
| # Normalize quality | |
| QUALITY=$(echo "$QUALITY" | tr -cd '0-9') | |
| [ -z "$QUALITY" ] && QUALITY=82 | |
| [ "$QUALITY" -lt 1 ] 2>/dev/null && QUALITY=1 | |
| [ "$QUALITY" -gt 100 ] 2>/dev/null && QUALITY=100 | |
| # FORMAT and DEST pass through to the worker case statements; | |
| # any value not matching the exact arms falls into the documented defaults | |
| # ("Keep original" → keep, "Replace original ..." → replace + .bak). | |
| # --- Build temp script with params baked in, run in Terminal ------- | |
| TMPSCRIPT=$(mktemp /tmp/imgopt_run.XXXXXX).sh | |
| cat > "$TMPSCRIPT" <<HEADER | |
| #!/bin/bash | |
| export PATH="/opt/homebrew/bin:/usr/local/bin:\$PATH" | |
| HEADER | |
| printf 'MAX_DIM=%q\n' "$MAX_DIM" >> "$TMPSCRIPT" | |
| printf 'FORMAT=%q\n' "$FORMAT" >> "$TMPSCRIPT" | |
| printf 'QUALITY=%q\n' "$QUALITY" >> "$TMPSCRIPT" | |
| printf 'DEST=%q\n' "$DEST" >> "$TMPSCRIPT" | |
| echo 'INPUT_FILES=()' >> "$TMPSCRIPT" | |
| for f in "${INPUT_FILES[@]}"; do | |
| printf 'INPUT_FILES+=( %q )\n' "$f" >> "$TMPSCRIPT" | |
| done | |
| cat >> "$TMPSCRIPT" <<'BODY' | |
| human_size() { | |
| awk -v b="$1" 'BEGIN{u="B";if(b>=1024){b/=1024;u="KB"};if(b>=1024){b/=1024;u="MB"};printf "%.1f %s",b,u}' | |
| } | |
| N=${#INPUT_FILES[@]} | |
| echo "" | |
| echo "════════════════════════════════════════════" | |
| echo " IMAGE OPTIMIZER — $N file(s)" | |
| echo " resize=${MAX_DIM} format=$FORMAT quality=$QUALITY" | |
| echo "════════════════════════════════════════════" | |
| echo "" | |
| FAIL=0 | |
| SUM_IN=0 | |
| SUM_OUT=0 | |
| LAST_OUT_DIR="" | |
| for i in "${!INPUT_FILES[@]}"; do | |
| SRC="${INPUT_FILES[$i]}" | |
| SRC_BASE="$(basename "$SRC")" | |
| SRC_DIR="$(cd "$(dirname "$SRC")" && pwd)" | |
| SRC_STEM="${SRC_BASE%.*}" | |
| SRC_EXT="${SRC_BASE##*.}" | |
| SRC_EXT_LC=$(echo "$SRC_EXT" | tr '[:upper:]' '[:lower:]') | |
| case "$FORMAT" in | |
| WebP) OUT_EXT="webp" ;; | |
| JPEG) OUT_EXT="jpg" ;; | |
| PNG) OUT_EXT="png" ;; | |
| *) | |
| # "Keep original format" — normalize a couple of weird extensions | |
| case "$SRC_EXT_LC" in | |
| jpeg) OUT_EXT="jpg" ;; | |
| tiff) OUT_EXT="tif" ;; | |
| heif) OUT_EXT="heic" ;; | |
| *) OUT_EXT="$SRC_EXT_LC" ;; | |
| esac ;; | |
| esac | |
| case "$DEST" in | |
| "Save alongside original (add _opt suffix)") | |
| OUT_DIR="$SRC_DIR" | |
| OUT_PATH="$OUT_DIR/${SRC_STEM}_opt.$OUT_EXT" ;; | |
| "Save into 'optimized' subfolder") | |
| OUT_DIR="$SRC_DIR/optimized" | |
| mkdir -p "$OUT_DIR" | |
| OUT_PATH="$OUT_DIR/${SRC_STEM}.$OUT_EXT" ;; | |
| *) | |
| OUT_DIR="$SRC_DIR" | |
| cp -p "$SRC" "$SRC.bak" | |
| OUT_PATH="$OUT_DIR/${SRC_STEM}.$OUT_EXT" ;; | |
| esac | |
| LAST_OUT_DIR="$OUT_DIR" | |
| IN_SZ=$(stat -f %z "$SRC" 2>/dev/null || echo 0) | |
| # Build magick args (resize only downward with `>`) | |
| ARGS=( "$SRC" ) | |
| [ "$MAX_DIM" != "0" ] && ARGS+=( -resize "${MAX_DIM}x${MAX_DIM}>" ) | |
| ARGS+=( -strip -quality "$QUALITY" ) | |
| printf "[%d/%d] %s\n" "$((i+1))" "$N" "$SRC_BASE" | |
| OK=0 | |
| if [ "$OUT_EXT" = "png" ] && command -v pngquant >/dev/null 2>&1; then | |
| QMAX="$QUALITY" | |
| QMIN=$((QMAX - 20)) | |
| [ "$QMIN" -lt 40 ] && QMIN=40 | |
| if magick "${ARGS[@]}" png:- 2>/dev/null \ | |
| | pngquant --quality "${QMIN}-${QMAX}" --speed 1 --force --output "$OUT_PATH" - 2>/dev/null; then | |
| OK=1 | |
| fi | |
| # Fallback: plain magick if pngquant pipe failed (e.g. very low color depth source) | |
| [ $OK -eq 0 ] && magick "${ARGS[@]}" "$OUT_PATH" 2>/dev/null && OK=1 | |
| else | |
| magick "${ARGS[@]}" "$OUT_PATH" 2>/dev/null && OK=1 | |
| fi | |
| if [ $OK -eq 1 ] && [ -f "$OUT_PATH" ] && [ -s "$OUT_PATH" ]; then | |
| OUT_SZ=$(stat -f %z "$OUT_PATH" 2>/dev/null || echo 0) | |
| SUM_IN=$((SUM_IN + IN_SZ)) | |
| SUM_OUT=$((SUM_OUT + OUT_SZ)) | |
| if [ "$IN_SZ" -gt 0 ]; then | |
| PCT=$(awk -v a="$OUT_SZ" -v b="$IN_SZ" 'BEGIN{printf "%.0f", (1-a/b)*100}') | |
| else | |
| PCT="?" | |
| fi | |
| printf " %s → %s (-%s%%) %s\n" "$(human_size "$IN_SZ")" "$(human_size "$OUT_SZ")" "$PCT" "$(basename "$OUT_PATH")" | |
| else | |
| echo " FAILED — magick could not write $OUT_PATH" | |
| FAIL=$((FAIL+1)) | |
| fi | |
| done | |
| echo "" | |
| echo "────────────────────────────────────────────" | |
| if [ "$SUM_IN" -gt 0 ]; then | |
| PCT_TOT=$(awk -v a="$SUM_OUT" -v b="$SUM_IN" 'BEGIN{printf "%.0f", (1-a/b)*100}') | |
| echo "Total: $(human_size "$SUM_IN") → $(human_size "$SUM_OUT") (-${PCT_TOT}%)" | |
| fi | |
| if [ "$FAIL" -eq 0 ]; then | |
| osascript -e "display notification \"$N image(s) optimized.\" with title \"Image Optimizer\" sound name \"Glass\"" | |
| [ -n "$LAST_OUT_DIR" ] && open "$LAST_OUT_DIR" | |
| echo "" | |
| echo "Done. Press any key to close..." | |
| else | |
| osascript -e "display notification \"$FAIL of $N failed. See terminal.\" with title \"Image Optimizer\" sound name \"Basso\"" | |
| echo "" | |
| echo "$FAIL of $N failed. Press any key to close..." | |
| fi | |
| read -n 1 -s | |
| BODY | |
| chmod +x "$TMPSCRIPT" | |
| osascript <<EOF | |
| tell application "Terminal" | |
| activate | |
| do script "$TMPSCRIPT; rm -f $TMPSCRIPT" | |
| end tell | |
| EOF |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment