AVIF convert using inotify

inotify file watcher & AVIF conversion on Arch Linux

  1. Systemd Service (~/.config/systemd/user/image-watcher.service) - Runs the watch-and-convert.sh script automatically - Restarts on failure with a 10-second delay - Enabled to start at login
  2. Watch Script (watch-and-convert.sh:17) - Uses inotifywait (the inotify command-line tool) - Monitors ~/path/to/images recursively - Watches for two events: create and moved_to - Outputs the full file path when events occur
  3. Processing Pipeline - The script pipes inotifywait output to a while read loop - Checks if the file extension is an image format (jpg, png, gif, etc.) - Calls convert-to-avif.py on each new/moved image file
sudo pacman -Syu inotify-tools

systemd service

echo "[Unit]
Description=WebP Image Auto-Converter Watcher
After=default.target

[Service]
Type=simple
ExecStart=/path/to/script/watch-and-convert.sh
Restart=always
RestartSec=10

[Install]
WantedBy=default.target" > ~/.config/systemd/user/image-watcher.service

watch-and-convert.sh

#!/bin/bash

WATCH_DIR="$HOME/path/to/images"
SCRIPT_DIR="$HOME/path/to/script/dir"
CONVERTER="$SCRIPT_DIR/convert-to-avif.py"

echo "Starting image watcher..."
echo "Watching: $WATCH_DIR"
echo "Converting new images to AVIF at 85% quality"
echo ""

# Watch for new files or files moved into the directory
# -m = monitor (don't exit after first event)
# -r = recursive (watch subdirectories)
# -e = events to watch for
# --format = custom output format
inotifywait -m -r -e create -e moved_to --format '%w%f' "$WATCH_DIR" | while read filepath
do
    # Get file extension
    ext="${filepath##*.}"
    ext_lower=$(echo "$ext" | tr '[:upper:]' '[:lower:]')

    # Check if it's an image file we want to convert
    if  "$ext_lower" == "jpg" || "$ext_lower" == "jpeg" || "$ext_lower" == "png" || "$ext_lower" == "gif" || "$ext_lower" == "tiff" || "$ext_lower" == "bmp"  (link not found); then
        echo "[$(date '+%Y-%m-%d %H:%M:%S')] New image detected: $(basename "$filepath")"

        # Small delay to ensure file is fully written
        sleep 1

        # Convert just this file
        cd "$SCRIPT_DIR"
        python "$CONVERTER" "$filepath" 2>&1

        echo "---"
    fi
done

convert-to-avif.py

#!/usr/bin/env python3
#
# This command resizes images in a directory
# 
# When paired with a service like inotify
# it will watch the directory for changes 
# and change only that new file
#
# If ran against whole directory it will 
# convert all images.
#
# You have been warned.
#


import os
import sys
from pathlib import Path
from PIL import Image
import pillow_heif  # Provides AVIF support through libheif

# Register HEIF/AVIF formats
pillow_heif.register_heif_opener()

AVIF_QUALITY = 85
MAX_WIDTH = 2000
MAX_HEIGHT = 2000
IMAGE_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.gif', '.tiff', '.bmp', '.webp'}

def convert_and_delete_original(image_path):
    """Convert an image to AVIF format and delete the original."""
    try:
        img = Image.open(image_path)

        # Skip animated images
        if getattr(img, 'is_animated', False):
            print(f"⊘ Skipping {image_path.name}: animated images not supported")
            return True  # Not a failure, just skipped

        original_size = img.size

        # Check if resize is needed
        needs_resize = img.width > MAX_WIDTH or img.height > MAX_HEIGHT

        if needs_resize:
            # Calculate new dimensions maintaining aspect ratio
            img.thumbnail((MAX_WIDTH, MAX_HEIGHT), Image.Resampling.LANCZOS)

        # Create output path
        avif_path = image_path.with_suffix('.avif')

        # Convert to AVIF
        img.save(avif_path, 'AVIF', quality=AVIF_QUALITY)

        # Delete original
        image_path.unlink()

        resize_msg = f" [resized from {original_size[0]}x{original_size[1]}]" if needs_resize else ""
        print(f"✓ {image_path.name}{avif_path.name}{resize_msg} (original deleted)")
        return True

    except Exception as e:
        print(f"✗ Failed to convert {image_path.name}: {e}")
        return False

def process_directory(dir_path):
    """Recursively process all images in directory."""
    converted = 0
    failed = 0

    for entry in sorted(dir_path.rglob('*')):
        if entry.is_file() and entry.suffix.lower() in IMAGE_EXTENSIONS:
            if convert_and_delete_original(entry):
                converted += 1
            else:
                failed += 1

    return converted, failed

def main():
    target_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path.home() / 'Documents' / 'planetB' / 'images'

    if not target_path.exists():
        print(f"Error: Path '{target_path}' does not exist")
        sys.exit(1)

    # If it's a single file, process just that file
    if target_path.is_file():
        if target_path.suffix.lower() in IMAGE_EXTENSIONS:
            success = convert_and_delete_original(target_path)
            sys.exit(0 if success else 1)
        else:
            print(f"Error: {target_path.name} is not a supported image format")
            sys.exit(1)

    # Otherwise, process directory
    print(f"Converting images to AVIF ({AVIF_QUALITY}% quality)")
    print(f"Max dimensions: {MAX_WIDTH}x{MAX_HEIGHT} (larger images will be resized)")
    print(f"Source directory: {target_path}")
    print("NOTE: Original files will be DELETED after conversion\n")

    try:
        converted, failed = process_directory(target_path)

        print(f"\n✅ Conversion complete!")
        print(f"   Converted: {converted} images")
        if failed > 0:
            print(f"   Failed: {failed} images")
    except Exception as e:
        print(f"\n❌ Conversion failed: {e}")
        sys.exit(1)

if __name__ == '__main__':
    main()

Then run the following to start the service

systemctl --user enable image-watcher.service

systemctl --user start image-watcher.service

systemctl --user status image-watcher.service