#!/usr/bin/env bash # optimize-images.sh — Image auditor, metadata stripper, and WebP optimizer for fosscat.com # # Usage: # ./scripts/optimize-images.sh # Interactive mode # ./scripts/optimize-images.sh --dry-run # Show what would happen without changing anything # ./scripts/optimize-images.sh --yes # Skip all confirmation prompts # ./scripts/optimize-images.sh --audit-only # Only run the audit phase (no changes) set -euo pipefail # --------------------------------------------------------------------------- # Configuration # --------------------------------------------------------------------------- IMAGES_DIR="static/images" CONTENT_DIR="content" CONFIG_FILE="config.toml" MAX_WIDTH=2000 MAX_HEIGHT=2000 WEBP_QUALITY=82 # --------------------------------------------------------------------------- # CLI flags # --------------------------------------------------------------------------- DRY_RUN=false AUTO_YES=false AUDIT_ONLY=false for arg in "$@"; do case "$arg" in --dry-run) DRY_RUN=true ;; --yes|-y) AUTO_YES=true ;; --audit-only) AUDIT_ONLY=true ;; --help|-h) echo "Usage: $0 [--dry-run] [--yes] [--audit-only]" echo "" echo " --dry-run Show what would happen without making changes" echo " --yes, -y Skip confirmation prompts" echo " --audit-only Only run the audit (no modifications)" echo " --help, -h Show this help" exit 0 ;; *) echo "Unknown option: $arg" echo "Run $0 --help for usage" exit 1 ;; esac done # --------------------------------------------------------------------------- # Colors and formatting # --------------------------------------------------------------------------- RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' CYAN='\033[0;36m' BOLD='\033[1m' DIM='\033[2m' NC='\033[0m' # No Color info() { echo -e "${BLUE}[INFO]${NC} $*"; } success() { echo -e "${GREEN}[OK]${NC} $*"; } warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } error() { echo -e "${RED}[ERROR]${NC} $*"; } header() { echo -e "\n${BOLD}${CYAN}═══ $* ═══${NC}\n"; } # --------------------------------------------------------------------------- # Dependency checks # --------------------------------------------------------------------------- check_deps() { local missing=() for cmd in exiftool convert identify cwebp; do if ! command -v "$cmd" &>/dev/null; then missing+=("$cmd") fi done if [[ ${#missing[@]} -gt 0 ]]; then error "Missing required tools: ${missing[*]}" echo " These are provided by the Nix dev shell. Run:" echo " nix develop # or let direnv load the flake" echo "" echo " Required nix packages:" echo " perl538Packages.ImageExifTool (exiftool)" echo " imagemagick (convert, identify)" echo " libwebp (cwebp)" exit 1 fi } # --------------------------------------------------------------------------- # Ensure we're in the project root # --------------------------------------------------------------------------- if [[ ! -f "$CONFIG_FILE" ]] || [[ ! -d "$IMAGES_DIR" ]]; then error "Must be run from the project root (where $CONFIG_FILE and $IMAGES_DIR exist)" exit 1 fi check_deps # --------------------------------------------------------------------------- # Utility: human-readable file size # --------------------------------------------------------------------------- human_size() { local bytes=$1 if (( bytes >= 1048576 )); then local mb_whole=$(( bytes / 1048576 )) local mb_frac=$(( (bytes % 1048576) * 10 / 1048576 )) echo "${mb_whole}.${mb_frac} MB" elif (( bytes >= 1024 )); then echo "$(( bytes / 1024 )) KB" else echo "${bytes} B" fi } # --------------------------------------------------------------------------- # Utility: confirm prompt (respects --yes and --dry-run) # --------------------------------------------------------------------------- confirm() { local prompt="$1" if $AUTO_YES; then return 0 fi if $DRY_RUN; then echo -e " ${DIM}(dry-run: would ask) $prompt${NC}" return 0 fi echo -en " $prompt ${BOLD}[y/N]${NC} " read -r answer [[ "$answer" =~ ^[Yy]$ ]] } # --------------------------------------------------------------------------- # PHASE 1: AUDIT # --------------------------------------------------------------------------- phase_audit() { header "PHASE 1: IMAGE AUDIT" # Collect all image files local -a image_files=() while IFS= read -r -d '' f; do image_files+=("$f") done < <(find "$IMAGES_DIR" -maxdepth 1 -type f \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.webp' -o -iname '*.gif' \) -print0 | sort -z) if [[ ${#image_files[@]} -eq 0 ]]; then warn "No images found in $IMAGES_DIR" return fi # --- Image inventory table --- echo -e "${BOLD}Image Inventory${NC}" printf " %-40s %-6s %-12s %s\n" "FILENAME" "FORMAT" "DIMENSIONS" "SIZE" printf " %-40s %-6s %-12s %s\n" "--------" "------" "----------" "----" local total_size=0 for img in "${image_files[@]}"; do local fname fname=$(basename "$img") local ext="${fname##*.}" local fsize fsize=$(stat -c%s "$img" 2>/dev/null || stat -f%z "$img" 2>/dev/null) total_size=$((total_size + fsize)) local dims dims=$(identify -format "%wx%h" "$img" 2>/dev/null || echo "unknown") printf " %-40s %-6s %-12s %s\n" "$fname" "$ext" "$dims" "$(human_size "$fsize")" done echo "" info "Total: ${#image_files[@]} images, $(human_size $total_size)" # --- EXIF / Metadata scan --- echo "" echo -e "${BOLD}Metadata / Privacy Scan${NC}" local privacy_issues=0 # Sensitive tag names to check (extracted in a single exiftool call per image) local sensitive_tag_args=( -GPSLatitude -GPSLongitude -GPSPosition -SerialNumber -CameraSerialNumber -BodySerialNumber -LensSerialNumber -OwnerName -Artist -Copyright -Creator -Rights -By-line -Contact -Make -Model -LensModel -Software -DateTime -DateTimeOriginal -CreateDate -CreatorTool -ImageDescription -UserComment ) for img in "${image_files[@]}"; do local fname fname=$(basename "$img") local has_metadata=false local metadata_lines=() # Single exiftool call to extract all sensitive tags at once local exif_output exif_output=$(exiftool -s -f "${sensitive_tag_args[@]}" "$img" 2>/dev/null || true) while IFS= read -r line; do [[ -z "$line" ]] && continue # exiftool -s output format: "TagName : value" local tagname value tagname=$(echo "$line" | sed 's/\s*:.*//' | xargs) value=$(echo "$line" | sed 's/^[^:]*:\s*//') # Skip tags with no value (exiftool -f shows "-" for missing tags) [[ "$value" == "-" ]] && continue [[ -z "$value" ]] && continue has_metadata=true # Highlight GPS data in red if [[ "$tagname" == *GPS* ]] || [[ "$tagname" == *Latitude* ]] || [[ "$tagname" == *Longitude* ]]; then metadata_lines+=("${RED}!!${NC} $tagname: $value") elif [[ "$tagname" == *Serial* ]] || [[ "$tagname" == *Owner* ]] || [[ "$tagname" == *Artist* ]] || [[ "$tagname" == *Creator* ]]; then metadata_lines+=("${YELLOW}!${NC} $tagname: $value") else metadata_lines+=("${DIM}-${NC} $tagname: $value") fi done <<< "$exif_output" if $has_metadata; then privacy_issues=$((privacy_issues + 1)) echo -e " ${YELLOW}$fname${NC} — metadata found:" for line in "${metadata_lines[@]}"; do echo -e " $line" done else echo -e " ${GREEN}$fname${NC} — clean" fi done echo "" if [[ $privacy_issues -gt 0 ]]; then warn "$privacy_issues image(s) contain metadata that should be stripped" else success "All images are clean of sensitive metadata" fi # --- Cross-reference with content --- echo "" echo -e "${BOLD}Content Reference Check${NC}" # Collect all image references from content files local -a referenced_images=() local -a broken_refs=() local -a inconsistent_paths=() while IFS= read -r -d '' mdfile; do # Front matter image field (handles both `image: "..."` and ` image: "..."` under cover:) while IFS= read -r fm_image; do [[ -z "$fm_image" ]] && continue # Clean up: remove surrounding quotes and whitespace fm_image=$(echo "$fm_image" | sed 's/^[[:space:]]*image:[[:space:]]*//' | sed 's/^["'\'']//' | sed 's/["'\'']\s*$//') if [[ -n "$fm_image" ]] && [[ "$fm_image" != '""' ]] && [[ "$fm_image" != http* ]]; then # Normalize: Hugo serves /images/... from static/images/... local fs_path="static/${fm_image#/}" # Check if it's a broken reference if [[ ! -f "$fs_path" ]]; then broken_refs+=("$mdfile|$fm_image") else referenced_images+=("$fs_path") fi # Check for inconsistent path (missing leading /) if [[ "$fm_image" != /* ]]; then inconsistent_paths+=("$mdfile|$fm_image") fi fi done < <(grep -E '^\s*image:\s' "$mdfile" 2>/dev/null || true) # Inline markdown images: ![alt](/images/foo.jpg#center) while IFS= read -r inline_ref; do [[ -z "$inline_ref" ]] && continue # Strip #fragment local clean_ref="${inline_ref%%#*}" local fs_ref="static/${clean_ref#/}" if [[ ! -f "$fs_ref" ]] && [[ "$clean_ref" != http* ]]; then broken_refs+=("$mdfile|$inline_ref") else referenced_images+=("$fs_ref") fi done < <(grep -oP '!\[[^\]]*\]\(\K[^)]+' "$mdfile" 2>/dev/null || true) done < <(find "$CONTENT_DIR" -name '*.md' -print0) # Also check config.toml for avatarUrl local avatar_path avatar_path=$(grep 'avatarUrl' "$CONFIG_FILE" | sed 's/.*=\s*["'\'']\(.*\)["'\'']/\1/' || true) if [[ -n "$avatar_path" ]]; then referenced_images+=("static/${avatar_path#/}") fi # Find unreferenced images (compare using static/images/... paths) local -a unreferenced=() for img in "${image_files[@]}"; do local found=false for ref in "${referenced_images[@]}"; do if [[ "$ref" == "$img" ]]; then found=true break fi done if ! $found; then unreferenced+=("$img") fi done # Report broken references if [[ ${#broken_refs[@]} -gt 0 ]]; then warn "${#broken_refs[@]} broken image reference(s):" for entry in "${broken_refs[@]}"; do local file="${entry%%|*}" local ref="${entry##*|}" echo -e " ${RED}$ref${NC} in ${DIM}$file${NC}" done else success "No broken image references" fi # Report unreferenced images echo "" if [[ ${#unreferenced[@]} -gt 0 ]]; then warn "${#unreferenced[@]} unreferenced image(s) (not used in any content):" for img in "${unreferenced[@]}"; do local fsize fsize=$(stat -c%s "$img" 2>/dev/null || stat -f%z "$img" 2>/dev/null) echo -e " ${YELLOW}$(basename "$img")${NC} ($(human_size "$fsize"))" done else success "All images are referenced in content" fi # Report inconsistent paths if [[ ${#inconsistent_paths[@]} -gt 0 ]]; then echo "" warn "${#inconsistent_paths[@]} image path(s) missing leading '/':" for entry in "${inconsistent_paths[@]}"; do local file="${entry%%|*}" local ref="${entry##*|}" echo -e " ${YELLOW}$ref${NC} in ${DIM}$file${NC}" done fi # Export arrays for later phases (bash 4+ trick: print to temp files) printf '%s\n' "${image_files[@]}" > /tmp/optimg_files.txt printf '%s\n' "${unreferenced[@]+"${unreferenced[@]}"}" > /tmp/optimg_unreferenced.txt printf '%s\n' "${broken_refs[@]+"${broken_refs[@]}"}" > /tmp/optimg_broken.txt echo "$total_size" > /tmp/optimg_total_size.txt } # --------------------------------------------------------------------------- # PHASE 2: METADATA STRIPPING # --------------------------------------------------------------------------- phase_strip_metadata() { header "PHASE 2: METADATA STRIPPING" if $DRY_RUN; then info "(dry-run) Would strip all EXIF/IPTC/XMP metadata from images" echo "" return fi local -a image_files=() mapfile -t image_files < /tmp/optimg_files.txt local stripped=0 for img in "${image_files[@]}"; do [[ -z "$img" ]] && continue local fname fname=$(basename "$img") # Check if image has strippable EXIF/XMP/IPTC metadata (not just file properties) # Use -EXIF:All -XMP:All -IPTC:All to only check real metadata groups local meta_check meta_check=$(exiftool -s -s -s -EXIF:All -XMP:All -IPTC:All "$img" 2>/dev/null || true) if [[ -z "$meta_check" ]]; then echo -e " ${DIM}$fname — already clean, skipping${NC}" continue fi # Auto-orient JPEG/PNG before stripping (applies EXIF rotation to pixels) local ext="${fname##*.}" ext=$(echo "$ext" | tr '[:upper:]' '[:lower:]') if [[ "$ext" == "jpg" ]] || [[ "$ext" == "jpeg" ]] || [[ "$ext" == "png" ]]; then magick "$img" -auto-orient "$img" 2>/dev/null || true fi # Strip all metadata exiftool -all= -overwrite_original "$img" 2>/dev/null stripped=$((stripped + 1)) echo -e " ${GREEN}$fname${NC} — metadata stripped" done echo "" success "Stripped metadata from $stripped image(s)" } # --------------------------------------------------------------------------- # PHASE 3: CONVERT & COMPRESS # --------------------------------------------------------------------------- phase_convert() { header "PHASE 3: CONVERT TO WEBP & COMPRESS" local -a image_files=() mapfile -t image_files < /tmp/optimg_files.txt # Delete unreferenced images first local -a unreferenced=() mapfile -t unreferenced < /tmp/optimg_unreferenced.txt if [[ ${#unreferenced[@]} -gt 0 ]] && [[ -n "${unreferenced[0]}" ]]; then echo -e "${BOLD}Removing unreferenced images${NC}" for img in "${unreferenced[@]}"; do [[ -z "$img" ]] && continue local fsize fsize=$(stat -c%s "$img" 2>/dev/null || stat -f%z "$img" 2>/dev/null) if $DRY_RUN; then echo -e " ${DIM}(dry-run) Would delete: $(basename "$img") ($(human_size "$fsize"))${NC}" else rm -f "$img" echo -e " ${RED}Deleted:${NC} $(basename "$img") ($(human_size "$fsize"))" fi done echo "" fi echo -e "${BOLD}Converting images to WebP (quality $WEBP_QUALITY, max ${MAX_WIDTH}x${MAX_HEIGHT})${NC}" printf " %-40s %-12s %-12s %s\n" "FILENAME" "BEFORE" "AFTER" "SAVINGS" printf " %-40s %-12s %-12s %s\n" "--------" "------" "-----" "-------" local total_before=0 local total_after=0 local converted=0 for img in "${image_files[@]}"; do [[ -z "$img" ]] && continue # Skip if this was an unreferenced file we just deleted [[ ! -f "$img" ]] && continue local fname fname=$(basename "$img") local ext="${fname##*.}" local base="${fname%.*}" ext_lower=$(echo "$ext" | tr '[:upper:]' '[:lower:]') local webp_path="$IMAGES_DIR/${base}.webp" local before_size before_size=$(stat -c%s "$img" 2>/dev/null || stat -f%z "$img" 2>/dev/null) total_before=$((total_before + before_size)) if $DRY_RUN; then echo -e " ${DIM}(dry-run) Would convert: $fname -> ${base}.webp${NC}" # Estimate: assume 80% reduction for JPEGs, 70% for PNGs, 10% for existing WebP local est_after=$before_size case "$ext_lower" in jpg|jpeg) est_after=$((before_size / 5)) ;; png) est_after=$((before_size / 3)) ;; webp) est_after=$((before_size * 9 / 10)) ;; esac total_after=$((total_after + est_after)) converted=$((converted + 1)) continue fi # Get current dimensions local cur_width cur_height read -r cur_width cur_height < <(identify -format "%w %h\n" "$img" 2>/dev/null || echo "0 0") local needs_resize=false if (( cur_width > MAX_WIDTH )) || (( cur_height > MAX_HEIGHT )); then needs_resize=true fi # Determine the input for cwebp local cwebp_input="$img" local tmp_resized="" if $needs_resize; then # Resize via ImageMagick, output to temp PNG for cwebp tmp_resized=$(mktemp /tmp/optimg_XXXXXX.png) magick "$img" -resize "${MAX_WIDTH}x${MAX_HEIGHT}>" -quality 100 "$tmp_resized" info " Resized $fname: ${cur_width}x${cur_height} -> $(magick identify -format '%wx%h' "$tmp_resized")" cwebp_input="$tmp_resized" fi # Convert to WebP via cwebp (handles JPEG/PNG/WebP input natively) if [[ "$ext_lower" == "webp" ]] && [[ "$img" == "$webp_path" ]]; then # Same input and output: use temp output local tmp_webp tmp_webp=$(mktemp /tmp/optimg_XXXXXX.webp) cwebp -q "$WEBP_QUALITY" "$cwebp_input" -o "$tmp_webp" 2>/dev/null mv "$tmp_webp" "$webp_path" else cwebp -q "$WEBP_QUALITY" "$cwebp_input" -o "$webp_path" 2>/dev/null fi # Cleanup temp file if we resized [[ -n "$tmp_resized" ]] && rm -f "$tmp_resized" # Step 3: Delete original if it's not already .webp if [[ "$ext_lower" != "webp" ]]; then rm -f "$img" fi local after_size after_size=$(stat -c%s "$webp_path" 2>/dev/null || stat -f%z "$webp_path" 2>/dev/null) total_after=$((total_after + after_size)) local savings=0 if (( before_size > 0 )); then savings=$(( (before_size - after_size) * 100 / before_size )) fi local savings_color="$GREEN" if (( savings < 10 )); then savings_color="$YELLOW" fi printf " %-40s %-12s %-12s ${savings_color}%s%%${NC}\n" \ "${base}.webp" "$(human_size "$before_size")" "$(human_size "$after_size")" "$savings" converted=$((converted + 1)) done echo "" local total_savings=0 if (( total_before > 0 )); then total_savings=$(( (total_before - total_after) * 100 / total_before )) fi info "Converted $converted image(s)" info "Total: $(human_size $total_before) -> $(human_size $total_after) (${total_savings}% reduction)" # Save totals for summary echo "$total_before" > /tmp/optimg_total_before.txt echo "$total_after" > /tmp/optimg_total_after.txt echo "$converted" > /tmp/optimg_converted.txt } # --------------------------------------------------------------------------- # PHASE 4: UPDATE CONTENT REFERENCES # --------------------------------------------------------------------------- phase_update_refs() { header "PHASE 4: UPDATE CONTENT REFERENCES" local updated_files=0 # --- Step 1: Update image extensions in content files --- # This must happen BEFORE broken ref clearing, since .jpg/.png files are now .webp echo -e "${BOLD}Updating image references (.jpg/.jpeg/.png -> .webp)${NC}" while IFS= read -r -d '' mdfile; do local changed=false # Normalize front matter paths first: change image: "images/... to image: "/images/... if grep -qE '^\s*image:\s*"images/' "$mdfile" 2>/dev/null; then if ! $DRY_RUN; then sed -i -E 's@^(\s*image:\s*)"images/@\1"/images/@' "$mdfile" fi changed=true fi # Update front matter image field (only local paths, not http URLs) # Handles both `image: "/images/..."` and ` image: "/images/..."` (indented under cover:) if grep -qE '^\s*image:\s*"/images/.*\.(jpg|jpeg|JPG|JPEG|png|PNG)"' "$mdfile" 2>/dev/null; then if ! $DRY_RUN; then sed -i -E 's@^(\s*image:\s*"/images/[^"]*)\.(jpg|jpeg|JPG|JPEG|png|PNG)"@\1.webp"@' "$mdfile" fi changed=true fi # Update inline markdown images: ![alt](/images/foo.jpg#center) # Only match local /images/ paths, not external URLs if grep -qP '!\[[^\]]*\]\(/images/[^)]*\.(jpg|jpeg|JPG|JPEG|png|PNG)(#[^)]*)?\)' "$mdfile" 2>/dev/null; then if ! $DRY_RUN; then sed -i -E 's@(!\[[^]]*\]\(/images/[^.)]*)\.(jpg|jpeg|JPG|JPEG|png|PNG)([#][^)]*)?(\))@\1.webp\3\4@g' "$mdfile" fi changed=true fi if $changed; then local relpath="${mdfile}" if $DRY_RUN; then echo -e " ${DIM}(dry-run) Would update refs in: $relpath${NC}" else echo -e " ${GREEN}Updated${NC} $relpath" fi updated_files=$((updated_files + 1)) fi done < <(find "$CONTENT_DIR" -name '*.md' -print0) # --- Step 2: Update config.toml avatar --- if grep -q 'avatarUrl.*\.png' "$CONFIG_FILE" 2>/dev/null; then if $DRY_RUN; then echo -e " ${DIM}(dry-run) Would update avatarUrl in $CONFIG_FILE${NC}" else sed -i 's@avatarUrl = "/images/fosscat_icon\.png"@avatarUrl = "/images/fosscat_icon.webp"@' "$CONFIG_FILE" echo -e " ${GREEN}Updated${NC} avatarUrl in $CONFIG_FILE" fi updated_files=$((updated_files + 1)) fi # --- Step 3: Clear genuinely broken image references --- # Only clear refs that still don't resolve after extension updates # (e.g., placeholder /images/img.jpg that was never a real image) echo "" echo -e "${BOLD}Checking for remaining broken image references${NC}" local cleared=0 while IFS= read -r -d '' mdfile; do # Check front matter image fields while IFS= read -r fm_line; do [[ -z "$fm_line" ]] && continue local fm_image fm_image=$(echo "$fm_line" | sed 's/^[[:space:]]*image:[[:space:]]*//' | sed 's/^["'\'']//' | sed 's/["'\'']\s*$//') [[ -z "$fm_image" ]] && continue [[ "$fm_image" == '""' ]] && continue [[ "$fm_image" == http* ]] && continue local fs_path="static/${fm_image#/}" if [[ ! -f "$fs_path" ]]; then if $DRY_RUN; then echo -e " ${DIM}(dry-run) Would clear broken ref in: $mdfile (was: $fm_image)${NC}" else local escaped_image escaped_image=$(echo "$fm_image" | sed 's/[.[\/*^$]/\\&/g') sed -i -E "s@^(\s*image:\s*).*${escaped_image}.*@\1\"\"@" "$mdfile" echo -e " ${GREEN}Cleared${NC} broken ref ${DIM}$fm_image${NC} in ${DIM}$mdfile${NC}" cleared=$((cleared + 1)) fi fi done < <(grep -E '^\s*image:\s' "$mdfile" 2>/dev/null || true) done < <(find "$CONTENT_DIR" -name '*.md' -print0) if [[ $cleared -eq 0 ]] && ! $DRY_RUN; then success "No broken image references remaining" fi echo "" info "Updated $updated_files file(s)" } # --------------------------------------------------------------------------- # PHASE 5: SUMMARY # --------------------------------------------------------------------------- phase_summary() { header "PHASE 5: SUMMARY" if $DRY_RUN; then echo -e "${BOLD}${YELLOW}DRY RUN — no changes were made${NC}" echo "" fi local total_before total_after converted total_before=$(cat /tmp/optimg_total_before.txt 2>/dev/null || cat /tmp/optimg_total_size.txt 2>/dev/null || echo 0) total_after=$(cat /tmp/optimg_total_after.txt 2>/dev/null || echo 0) converted=$(cat /tmp/optimg_converted.txt 2>/dev/null || echo 0) local savings=0 if (( total_before > 0 )) && (( total_after > 0 )); then savings=$(( (total_before - total_after) * 100 / total_before )) fi echo -e " Images processed: ${BOLD}$converted${NC}" if (( total_after > 0 )); then echo -e " Size before: ${BOLD}$(human_size "$total_before")${NC}" echo -e " Size after: ${BOLD}$(human_size "$total_after")${NC}" echo -e " Total reduction: ${BOLD}${GREEN}${savings}%${NC}" fi echo "" echo -e " ${BOLD}Next steps:${NC}" echo -e " 1. Run ${CYAN}hugo server${NC} and verify images look correct" echo -e " 2. Check the browser dev tools Network tab for proper WebP delivery" echo -e " 3. Commit when satisfied: ${CYAN}git add -A && git commit -m \"optimize: convert images to webp, strip metadata\"${NC}" # Cleanup temp files rm -f /tmp/optimg_*.txt } # --------------------------------------------------------------------------- # MAIN # --------------------------------------------------------------------------- main() { echo -e "${BOLD}${CYAN}" echo " ┌─────────────────────────────────────────┐" echo " │ fosscat.com Image Optimizer │" echo " │ Strip metadata · Convert to WebP │" echo " │ Resize · Audit references │" echo " └─────────────────────────────────────────┘" echo -e "${NC}" if $DRY_RUN; then echo -e " ${YELLOW}Running in DRY RUN mode — no files will be modified${NC}" echo "" fi # Phase 1: Audit (always runs) phase_audit if $AUDIT_ONLY; then echo "" info "Audit complete. Run without --audit-only to process images." rm -f /tmp/optimg_*.txt return fi # Confirm before proceeding echo "" if ! $AUTO_YES && ! $DRY_RUN; then echo -en " ${BOLD}Proceed with optimization? [y/N]${NC} " read -r answer if [[ ! "$answer" =~ ^[Yy]$ ]]; then info "Aborted." rm -f /tmp/optimg_*.txt exit 0 fi fi # Phase 2: Strip metadata phase_strip_metadata # Phase 3: Convert & compress phase_convert # Phase 4: Update references phase_update_refs # Phase 5: Summary phase_summary } main