Created
March 20, 2026 23:38
-
-
Save RexYuan/b502b8328100f18f98150d586ba2f52b to your computer and use it in GitHub Desktop.
TinyPNG-like PNG compression pipeline using pngquant(lossy) followed by oxipng(lossless optimization).
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 | |
| # | |
| # tinypng.sh | |
| # | |
| # TinyPNG-like PNG compression pipeline using pngquant (lossy) | |
| # followed by oxipng (lossless optimization). | |
| # | |
| # Notes: This script was vibe-coded but checked in-head by me. | |
| # | |
| # Strategy: | |
| # - Validate input files are PNG | |
| # - Apply perceptual palette quantization via pngquant | |
| # - Enforce quality bounds (fail if too lossy) | |
| # - Optimize resulting PNG with oxipng (DEFLATE tuning + stripping) | |
| # - Discard output if larger than original (unless overridden) | |
| # - Report size reduction with human-readable units and % saved | |
| # | |
| # Guarantees: | |
| # - No overwrite of original files | |
| # - No silently worse outputs | |
| # - Explicit failure reporting at every stage | |
| # | |
| # Usage: | |
| # ./tinypng.sh [options] file1.png [file2.png ...] | |
| # | |
| # Options: | |
| # -q, --quality MIN-MAX Quality range for pngquant (default: 60-80) | |
| # -s, --speed N pngquant speed 1–11 (default: 1) | |
| # -o, --optimization N oxipng level 0–6 (default: 4) | |
| # -j, --jobs N parallel jobs (default: 1) | |
| # --no-keep keep output even if larger | |
| # -h, --help show help | |
| # | |
| # Output: | |
| # input.png → input_tiny.png | |
| # | |
| # Examples: | |
| # ./tinypng.sh image.png | |
| # ./tinypng.sh -q 70-90 *.png | |
| # ./tinypng.sh -j 8 *.png | |
| # | |
| # Requirements: | |
| # - pngquant | |
| # - oxipng | |
| # | |
| # Install: | |
| # brew install pngquant oxipng | |
| set -uo pipefail | |
| ######################################## | |
| # Defaults (TinyPNG-like) | |
| ######################################## | |
| QUALITY="60-80" | |
| SPEED=1 | |
| OXI_LEVEL=4 | |
| KEEP_IF_LARGER=1 | |
| JOBS=1 | |
| ######################################## | |
| # Logging helpers | |
| ######################################## | |
| log() { echo "[INFO] $*"; } | |
| warn() { echo "[WARN] $*" >&2; } | |
| fail() { echo "[ERROR] $*" >&2; } | |
| ######################################## | |
| # Help | |
| ######################################## | |
| usage() { | |
| cat <<EOF | |
| Usage: tinypng.sh [options] file1.png [file2.png ...] | |
| Options: | |
| -q, --quality MIN-MAX (default: 60-80) | |
| -s, --speed N 1-11 (default: 1) | |
| -o, --optimization N 0-6 (default: 4) | |
| -j, --jobs N parallel jobs (default: 1) | |
| --no-keep keep output even if larger | |
| -h, --help show help | |
| Output: | |
| input.png → input_tiny.png | |
| EOF | |
| } | |
| ######################################## | |
| # Validation | |
| ######################################## | |
| validate_quality() { | |
| [[ "$1" =~ ^[0-9]{1,3}-[0-9]{1,3}$ ]] || { fail "Invalid quality format: $1"; return 1; } | |
| local min="${1%-*}" | |
| local max="${1#*-}" | |
| (( min >= 0 && max <= 100 && min <= max )) || { fail "Invalid quality range: $1"; return 1; } | |
| } | |
| validate_speed() { | |
| [[ "$1" =~ ^[0-9]+$ ]] || { fail "Invalid speed: $1"; return 1; } | |
| (( $1 >= 1 && $1 <= 11 )) || { fail "Speed must be 1-11"; return 1; } | |
| } | |
| validate_oxi() { | |
| [[ "$1" =~ ^[0-9]+$ ]] || { fail "Invalid optimization level: $1"; return 1; } | |
| (( $1 >= 0 && $1 <= 6 )) || { fail "Optimization must be 0-6"; return 1; } | |
| } | |
| ######################################## | |
| # Parse args | |
| ######################################## | |
| FILES=() | |
| while [[ $# -gt 0 ]]; do | |
| case "$1" in | |
| -q|--quality) QUALITY="$2"; validate_quality "$QUALITY" || exit 1; shift 2;; | |
| -s|--speed) SPEED="$2"; validate_speed "$SPEED" || exit 1; shift 2;; | |
| -o|--optimization) OXI_LEVEL="$2"; validate_oxi "$OXI_LEVEL" || exit 1; shift 2;; | |
| -j|--jobs) JOBS="$2"; shift 2;; | |
| --no-keep) KEEP_IF_LARGER=0; shift;; | |
| -h|--help) usage; exit 0;; | |
| *) FILES+=("$1"); shift;; | |
| esac | |
| done | |
| [[ ${#FILES[@]} -eq 0 ]] && { usage; exit 1; } | |
| ######################################## | |
| # Dependency check | |
| ######################################## | |
| command -v pngquant >/dev/null || { fail "pngquant not found"; exit 1; } | |
| command -v oxipng >/dev/null || { fail "oxipng not found"; exit 1; } | |
| ######################################## | |
| # Process function | |
| ######################################## | |
| process_file() { | |
| local input="$1" | |
| log "Processing: $input" | |
| # Validate file | |
| [[ -f "$input" ]] || { fail "$input does not exist"; return 1; } | |
| [[ "$input" == *.png ]] || { fail "$input is not a .png file"; return 1; } | |
| local dir base tmp out | |
| dir="$(dirname "$input")" | |
| base="$(basename "$input" .png)" | |
| tmp="$dir/${base}.tmp.png" | |
| out="$dir/${base}_tiny.png" | |
| ######################################## | |
| # Step 1: pngquant | |
| ######################################## | |
| log "Step 1/3: Quantization (pngquant)" | |
| if ! pngquant \ | |
| --quality="$QUALITY" \ | |
| --speed="$SPEED" \ | |
| --strip \ | |
| --force \ | |
| --output "$tmp" \ | |
| "$input"; then | |
| fail "pngquant failed on $input" | |
| rm -f "$tmp" | |
| return 1 | |
| fi | |
| [[ -f "$tmp" ]] || { fail "pngquant produced no output for $input"; return 1; } | |
| ######################################## | |
| # Step 2: oxipng | |
| ######################################## | |
| log "Step 2/3: Optimization (oxipng)" | |
| if ! oxipng \ | |
| -o "$OXI_LEVEL" \ | |
| --strip all \ | |
| --quiet \ | |
| --out "$out" \ | |
| "$tmp"; then | |
| fail "oxipng failed on $input" | |
| rm -f "$tmp" "$out" | |
| return 1 | |
| fi | |
| rm -f "$tmp" | |
| [[ -f "$out" ]] || { fail "oxipng produced no output for $input"; return 1; } | |
| ######################################## | |
| # Step 3: Size check + reporting | |
| ######################################## | |
| log "Step 3/3: Size validation" | |
| local orig_size new_size saved pct | |
| orig_size=$(stat -f%z "$input") || { fail "Failed to read size of $input"; return 1; } | |
| new_size=$(stat -f%z "$out") || { fail "Failed to read size of $out"; return 1; } | |
| # Human-readable formatter | |
| format_size() { | |
| local bytes=$1 | |
| awk ' | |
| function human(x) { | |
| s="B KB MB GB TB PB" | |
| split(s,arr) | |
| for(i=1; x>=1024 && i<length(arr); i++) x/=1024 | |
| return sprintf("%.2f %s", x, arr[i]) | |
| } | |
| BEGIN { print human('"$bytes"') } | |
| ' | |
| } | |
| # If keeping only smaller outputs | |
| if [[ "$KEEP_IF_LARGER" -eq 1 && $new_size -ge $orig_size ]]; then | |
| warn "Output larger than original, discarding" | |
| rm -f "$out" | |
| return 0 | |
| fi | |
| saved=$((orig_size - new_size)) | |
| # Avoid division by zero | |
| if (( orig_size > 0 )); then | |
| pct=$(awk "BEGIN { printf \"%.1f\", ($saved/$orig_size)*100 }") | |
| else | |
| pct="0.0" | |
| fi | |
| log "✔ Success: $out" | |
| printf " Size: %-10s → %-10s (↓%s%%)\n" \ | |
| "$(format_size "$orig_size")" \ | |
| "$(format_size "$new_size")" \ | |
| "$pct" | |
| } | |
| ######################################## | |
| # Execution | |
| ######################################## | |
| if [[ "$JOBS" -gt 1 ]]; then | |
| export -f process_file log warn fail | |
| printf "%s\0" "${FILES[@]}" | xargs -0 -P "$JOBS" -I{} bash -c 'process_file "$@"' _ {} | |
| else | |
| for f in "${FILES[@]}"; do | |
| process_file "$f" | |
| done | |
| fi | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment