The two previous posts built the monitoring infrastructure — WatchPaths on macOS, systemd path units and inotifywait on Linux — and promised the scripts would come later. The trigger is ready: launchd or systemd detects when something changes in a directory and fires a command. What is missing is the command itself.

This post delivers the image conversion script that those triggers will fire. The goal is simple: PNGs and JPGs go into a folder, WEBP or AVIF come out. The originals are deleted or moved, depending on the configuration. The script detects which encoders are available on the machine and picks the best one among those installed, with a fallback chain that ensures it works even when the ideal tool is not present. If no compatible encoder is found, the script tells you what to install and from which package manager.

The result is a script that can be used manually (./optimize-images.sh) or plugged directly into the plists and path units from the previous posts without any adaptation. The idea is that it works both on the Mac where someone writes blog posts and on the Linux server that processes uploads — the same logic, the same fallbacks, the same safeguards.

The problem with converting “later”#

Converting images to modern formats is one of those tasks everyone knows they should do and almost nobody does consistently. The reasons are well known: a 3 MB PNG becomes a 300 KB AVIF with visually indistinguishable quality; a 6 MB camera JPG drops to under 800 KB in WEBP. The difference is not marginal — it is an order of magnitude. For blogs, portfolios, documentation, e-commerce, any context where images are served over HTTP, the impact on load time and bandwidth consumption is direct and measurable.

The problem was never technical. Conversion tools have existed for years, both graphical and command-line. Google’s Squoosh converts in the browser. ImageMagick converts anything to anything. cwebp and avifenc are fast, free, and installable in one line. The barrier is behavioral: every image that passes through the workflow without being converted is a decision someone did not make. Save the PNG, open the converter, choose the format, adjust the quality, save the result, delete the original — that is six steps competing with everything else the person is doing at that moment. By the second week, the converter is no longer opened. By the third, the blog is serving 4 MB PNGs and nobody notices.

The solution is to eliminate the decision. The script in this post turns conversion into a process that happens without intervention: the image is saved to a directory, and the next time the user looks, it is already in WEBP or AVIF. Combined with launchd or systemd monitoring, the interval between saving and converting drops to seconds. The human handles the content; the machine handles the format.

The script#

Configuration#

The configuration block sits at the top of the script and concentrates all the decisions the user needs to make. There are five variables:

WATCH_DIR="$HOME/Pictures/optimize"
OUTPUT_FORMAT="avif"
QUALITY=80
ORIGINAL_ACTION="delete"
ORIGINALS_DIR="$WATCH_DIR/originals"

OUTPUT_FORMAT accepts avif or webp. The script validates the value before doing anything and exits with an error if it is something else.

QUALITY is the quality parameter passed to the encoder, on a scale from 0 to 100. The value 80 is a good starting point for photographic images — significant compression without visible artifacts. For screenshots with text and sharp edges, values between 85 and 95 better preserve sharpness. The parameter is passed directly to the chosen encoder, and although the scale is nominally the same across tools, the visual result for the same number may vary slightly between cwebp, avifenc, and ImageMagick. In practice, the difference is small enough not to justify conversion tables between encoders.

ORIGINAL_ACTION controls what happens to the source file after a successful conversion. With delete, the original is removed. With move, it is moved to the subdirectory defined in ORIGINALS_DIR — useful for anyone who wants a safety net before fully trusting the quality of automatic conversion. The originals directory is created automatically if it does not exist.

Encoder detection#

Before processing any image, the script needs to find out what is installed on the machine. The approach is straightforward: test the presence of each encoder with command -v, which returns success if the binary exists in PATH and fails silently if it does not.

detect_encoder() {
    local format
    format="$1"

    case "$format" in
        avif)
            if command -v avifenc >/dev/null 2>&1; then
                echo "avifenc"
            elif command -v magick >/dev/null 2>&1; then
                echo "magick"
            elif command -v convert >/dev/null 2>&1 && \
                 convert -list format 2>/dev/null | grep -qi "avif"; then
                echo "convert"
            elif command -v ffmpeg >/dev/null 2>&1 && \
                 ffmpeg -encoders 2>/dev/null | grep -q "libaom-av1"; then
                echo "ffmpeg"
            else
                echo ""
            fi
            ;;
        webp)
            if command -v cwebp >/dev/null 2>&1; then
                echo "cwebp"
            elif command -v magick >/dev/null 2>&1; then
                echo "magick"
            elif command -v convert >/dev/null 2>&1 && \
                 convert -list format 2>/dev/null | grep -qi "webp"; then
                echo "convert"
            elif command -v ffmpeg >/dev/null 2>&1 && \
                 ffmpeg -encoders 2>/dev/null | grep -q "libwebp"; then
                echo "ffmpeg"
            else
                echo ""
            fi
            ;;
    esac
}

The function returns the name of the chosen encoder as a string, or an empty string if no encoder is found. The caller checks the result and acts accordingly — either proceeds with conversion or exits with a help message.

For ImageMagick, the detection has a subtlety. Newer versions (7.x) use the magick binary as a unified entry point; older versions (6.x) use convert directly. The script tests magick first and falls back to convert if needed. But the presence of the binary does not guarantee format support — an ImageMagick installation compiled without the right delegates may not know how to read or write AVIF or WEBP. That is why the test with convert -list format comes before accepting ImageMagick as a valid encoder: if the format does not appear in the list of supported formats, the encoder is discarded and detection continues to the next candidate.

ffmpeg is the last resort in both chains. It is a video tool that also converts images, but it is not the best choice for the job — the parameters are less intuitive, the documentation is geared toward video workflows, and quality control for still images is less refined. It works, but if ffmpeg is the only encoder available, it is probably worth installing the dedicated tool.

The fallback hierarchy#

The order of preference is not arbitrary. For each format, the dedicated encoder comes first because it offers the best control over compression parameters and produces the best results for the same quality level.

For AVIF, the chain is: avifenc → ImageMagick → ffmpeg. avifenc (from the libavif package) is the reference — developed by the Alliance for Open Media, the same organization responsible for the format. It accepts granular parameters like speed (encoding speed vs. compression efficiency) and supports 10- and 12-bit color depth. ImageMagick delegates to libavif or libaom internally, so the result is comparable, but the fine-tuning parameters are hidden behind the generic convert interface. ffmpeg uses libaom-av1 and works, but the syntax for still images is uncomfortable — the concept of “one video frame” as an image is a forced fit.

For WEBP, the chain is: cwebp → ImageMagick → ffmpeg. cwebp (from Google’s webp package) is the reference encoder, with precise quality control, support for lossy and lossless compression profiles, and output optimized for photographic images. ImageMagick uses libwebp internally and produces equivalent results. ffmpeg with libwebp works but, again, is the least ergonomic option.

The conversion function receives the encoder name and dispatches to the correct syntax:

convert_image() {
    local input output encoder quality
    input="$1"
    output="$2"
    encoder="$3"
    quality="$4"

    case "$encoder" in
        avifenc)
            avifenc --min 0 --max 63 -a end-usage=q \
                -a cq-level=$((63 - quality * 63 / 100)) \
                --speed 6 "$input" "$output"
            ;;
        cwebp)
            cwebp -q "$quality" "$input" -o "$output"
            ;;
        magick)
            magick "$input" -quality "$quality" "$output"
            ;;
        convert)
            convert "$input" -quality "$quality" "$output"
            ;;
        ffmpeg)
            if [[ "$output" == *.avif ]]; then
                ffmpeg -y -i "$input" \
                    -c:v libaom-av1 -crf $((63 - quality * 63 / 100)) \
                    -still-picture 1 "$output" 2>/dev/null
            else
                ffmpeg -y -i "$input" \
                    -c:v libwebp -quality "$quality" \
                    "$output" 2>/dev/null
            fi
            ;;
    esac
}

avifenc has a quirk: its quality parameter (cq-level) uses an inverted scale, where 0 is the best quality and 63 is the worst. The script translates the 0–100 value to 63–0 automatically, so QUALITY=80 at the top of the script means the same thing regardless of which encoder is selected. ffmpeg with libaom-av1 uses crf on the same inverted scale and receives the same conversion. For cwebp, ImageMagick, and ffmpeg with libwebp, the value is passed directly — all three tools use 0–100 where higher values mean better quality.

Protection against incomplete files#

When the script is triggered by a WatchPaths or PathChanged, execution begins seconds after the file appears in the directory. But “appears” does not mean “is complete.” A browser saving a large image, an editing application exporting a high-resolution PNG, a cp from a slow network volume — in all these cases, the file is created (and the trigger fired) before the content has been fully written to disk.

Converting a partially written image produces one of two things: a corrupted output file that looks like half the image, or an encoder error that terminates the script. Neither is acceptable, especially if ORIGINAL_ACTION is set to delete — deleting the original of a file that was not successfully converted is data loss.

The protection is a loop that compares the file size at two moments separated by a short interval:

wait_for_stable() {
    local filepath size_before size_after
    filepath="$1"

    for _ in 1 2 3; do
        size_before=$(stat -c%s "$filepath" 2>/dev/null || \
                      stat -f%z "$filepath" 2>/dev/null || echo "0")
        sleep 1
        size_after=$(stat -c%s "$filepath" 2>/dev/null || \
                     stat -f%z "$filepath" 2>/dev/null || echo "0")

        if [[ "$size_before" == "$size_after" && "$size_after" != "0" ]]; then
            return 0
        fi
    done

    return 1
}

stat has different syntax on macOS and Linux — -c%s on GNU coreutils, -f%z on BSD. The script tries both and uses whichever works. The loop makes up to three attempts with a one-second interval between each comparison. If after three rounds the size is still changing, the function returns failure and the script skips that file — it will be processed on the next execution, when the trigger fires again after the write completes.

The "$size_after" != "0" test protects against a subtle edge case: a file that was created but is still empty (zero size in both measurements). This can happen when an application creates the file and opens the file descriptor but has not yet started writing. Without this check, the script would consider the file stable (the size did not change) and attempt to convert an empty file.

Conversion and cleanup#

The main loop scans the directory, processes each eligible file, and handles the originals:

process_images() {
    local encoder file basename output
    encoder=$(detect_encoder "$OUTPUT_FORMAT")

    if [[ -z "$encoder" ]]; then
        suggest_install "$OUTPUT_FORMAT"
        exit 1
    fi

    echo "$(date '+%Y-%m-%d %H:%M:%S') Encoder: $encoder (format: $OUTPUT_FORMAT)"

    find "$WATCH_DIR" -maxdepth 1 -type f \
        \( -iname '*.png' -o -iname '*.jpg' -o -iname '*.jpeg' \) |
    while IFS= read -r file; do
        basename="${file%.*}"
        output="${basename}.${OUTPUT_FORMAT}"

        if ! wait_for_stable "$file"; then
            echo "$(date '+%Y-%m-%d %H:%M:%S') Skipped (still writing): $file"
            continue
        fi

        if convert_image "$file" "$output" "$encoder" "$QUALITY"; then
            echo "$(date '+%Y-%m-%d %H:%M:%S') Converted: $file -> $output"
            handle_original "$file"
        else
            echo "$(date '+%Y-%m-%d %H:%M:%S') Failed: $file"
            rm -f "$output"
        fi
    done
}

The find with -maxdepth 1 avoids descending into subdirectories — including originals/, which would be disastrous if moved originals were reprocessed. The -iname makes the search case-insensitive, catching .PNG, .png, and .Jpg alike. The while IFS= read -r instead of for file in $(find ...) correctly handles filenames with spaces.

When conversion fails, rm -f "$output" deletes any partial output file the encoder may have created before aborting. Leaving a corrupted AVIF in the directory would cause confusion — it would look like the conversion succeeded, but the image would be broken.

The handle_original function encapsulates the decision configured in ORIGINAL_ACTION:

handle_original() {
    local file
    file="$1"

    case "$ORIGINAL_ACTION" in
        delete)
            rm -f "$file"
            ;;
        move)
            mkdir -p "$ORIGINALS_DIR"
            mv "$file" "$ORIGINALS_DIR/"
            ;;
    esac
}

The last piece is the function that suggests installation when no encoder is found:

suggest_install() {
    local format
    format="$1"

    echo "Error: no encoder found for $format."

    if [[ "$(uname)" == "Darwin" ]]; then
        case "$format" in
            avif) echo "Install with: brew install libavif" ;;
            webp) echo "Install with: brew install webp" ;;
        esac
    else
        case "$format" in
            avif) echo "Install with: sudo apt install libavif-bin" ;;
            webp) echo "Install with: sudo apt install webp" ;;
        esac
    fi
}

The OS detection uses unameDarwin for macOS, anything else assumes Linux with apt. This is a deliberate simplification: the script does not try to detect whether the system uses dnf, pacman, or apk. Anyone running Fedora or Arch knows how to find the equivalent package; anyone running Debian, Ubuntu, or any derivative — which is the vast majority of Linux servers in production — gets the correct suggestion.

The complete script#

Each function was explained in isolation in the previous sections. Here is the assembled script, ready to save to ~/bin/optimize-images.sh and make executable with chmod +x:

#!/usr/bin/env bash
set -euo pipefail

# -- Config ------------------------------------------------------------------

WATCH_DIR="$HOME/Pictures/optimize"
OUTPUT_FORMAT="avif"
QUALITY=80
ORIGINAL_ACTION="delete"
ORIGINALS_DIR="$WATCH_DIR/originals"

# -- Functions ---------------------------------------------------------------

detect_encoder() {
    local format
    format="$1"

    case "$format" in
        avif)
            if command -v avifenc >/dev/null 2>&1; then
                echo "avifenc"
            elif command -v magick >/dev/null 2>&1; then
                echo "magick"
            elif command -v convert >/dev/null 2>&1 && \
                 convert -list format 2>/dev/null | grep -qi "avif"; then
                echo "convert"
            elif command -v ffmpeg >/dev/null 2>&1 && \
                 ffmpeg -encoders 2>/dev/null | grep -q "libaom-av1"; then
                echo "ffmpeg"
            else
                echo ""
            fi
            ;;
        webp)
            if command -v cwebp >/dev/null 2>&1; then
                echo "cwebp"
            elif command -v magick >/dev/null 2>&1; then
                echo "magick"
            elif command -v convert >/dev/null 2>&1 && \
                 convert -list format 2>/dev/null | grep -qi "webp"; then
                echo "convert"
            elif command -v ffmpeg >/dev/null 2>&1 && \
                 ffmpeg -encoders 2>/dev/null | grep -q "libwebp"; then
                echo "ffmpeg"
            else
                echo ""
            fi
            ;;
    esac
}

suggest_install() {
    local format
    format="$1"

    echo "Error: no encoder found for $format."

    if [[ "$(uname)" == "Darwin" ]]; then
        case "$format" in
            avif) echo "Install with: brew install libavif" ;;
            webp) echo "Install with: brew install webp" ;;
        esac
    else
        case "$format" in
            avif) echo "Install with: sudo apt install libavif-bin" ;;
            webp) echo "Install with: sudo apt install webp" ;;
        esac
    fi
}

wait_for_stable() {
    local filepath size_before size_after
    filepath="$1"

    for _ in 1 2 3; do
        size_before=$(stat -c%s "$filepath" 2>/dev/null || \
                      stat -f%z "$filepath" 2>/dev/null || echo "0")
        sleep 1
        size_after=$(stat -c%s "$filepath" 2>/dev/null || \
                     stat -f%z "$filepath" 2>/dev/null || echo "0")

        if [[ "$size_before" == "$size_after" && "$size_after" != "0" ]]; then
            return 0
        fi
    done

    return 1
}

convert_image() {
    local input output encoder quality
    input="$1"
    output="$2"
    encoder="$3"
    quality="$4"

    case "$encoder" in
        avifenc)
            avifenc --min 0 --max 63 -a end-usage=q \
                -a cq-level=$((63 - quality * 63 / 100)) \
                --speed 6 "$input" "$output"
            ;;
        cwebp)
            cwebp -q "$quality" "$input" -o "$output"
            ;;
        magick)
            magick "$input" -quality "$quality" "$output"
            ;;
        convert)
            convert "$input" -quality "$quality" "$output"
            ;;
        ffmpeg)
            if [[ "$output" == *.avif ]]; then
                ffmpeg -y -i "$input" \
                    -c:v libaom-av1 -crf $((63 - quality * 63 / 100)) \
                    -still-picture 1 "$output" 2>/dev/null
            else
                ffmpeg -y -i "$input" \
                    -c:v libwebp -quality "$quality" \
                    "$output" 2>/dev/null
            fi
            ;;
    esac
}

handle_original() {
    local file
    file="$1"

    case "$ORIGINAL_ACTION" in
        delete)
            rm -f "$file"
            ;;
        move)
            mkdir -p "$ORIGINALS_DIR"
            mv "$file" "$ORIGINALS_DIR/"
            ;;
    esac
}

process_images() {
    local encoder file basename output
    encoder=$(detect_encoder "$OUTPUT_FORMAT")

    if [[ -z "$encoder" ]]; then
        suggest_install "$OUTPUT_FORMAT"
        exit 1
    fi

    echo "$(date '+%Y-%m-%d %H:%M:%S') Encoder: $encoder (format: $OUTPUT_FORMAT)"

    find "$WATCH_DIR" -maxdepth 1 -type f \
        \( -iname '*.png' -o -iname '*.jpg' -o -iname '*.jpeg' \) |
    while IFS= read -r file; do
        basename="${file%.*}"
        output="${basename}.${OUTPUT_FORMAT}"

        if ! wait_for_stable "$file"; then
            echo "$(date '+%Y-%m-%d %H:%M:%S') Skipped (still writing): $file"
            continue
        fi

        if convert_image "$file" "$output" "$encoder" "$QUALITY"; then
            echo "$(date '+%Y-%m-%d %H:%M:%S') Converted: $file -> $output"
            handle_original "$file"
        else
            echo "$(date '+%Y-%m-%d %H:%M:%S') Failed: $file"
            rm -f "$output"
        fi
    done
}

# -- Validation --------------------------------------------------------------

if [[ "$OUTPUT_FORMAT" != "avif" && "$OUTPUT_FORMAT" != "webp" ]]; then
    echo "Error: OUTPUT_FORMAT must be 'avif' or 'webp', got '$OUTPUT_FORMAT'"
    exit 1
fi

if [[ "$ORIGINAL_ACTION" != "delete" && "$ORIGINAL_ACTION" != "move" ]]; then
    echo "Error: ORIGINAL_ACTION must be 'delete' or 'move', got '$ORIGINAL_ACTION'"
    exit 1
fi

mkdir -p "$WATCH_DIR"

# -- Main --------------------------------------------------------------------

process_images

Save, make executable, and test manually before plugging into launchd or systemd:

chmod +x ~/bin/optimize-images.sh
cp some-photo.jpg ~/Pictures/optimize/
~/bin/optimize-images.sh
ls -lh ~/Pictures/optimize/

The ls -lh confirms that the AVIF (or WEBP) was created and shows the size comparison. If everything worked, the original file is gone (or was moved to originals/, depending on the configuration). From here, all that remains is to activate the trigger in the operating system — which is the subject of the next section.

Installing the encoders#

The script works with any encoder in the fallback chain, but the best results come from the dedicated tools — avifenc for AVIF and cwebp for WEBP. These offer the best quality control, better compression for the same visual level, and the most predictable parameters. ImageMagick and ffmpeg work as a safety net, not as a first choice.

macOS (Homebrew)#

For AVIF:

brew install libavif

The package installs avifenc (encoder) and avifdec (decoder). The encoder is compiled with libaom support, which is the reference AV1 implementation — the same one ffmpeg would use, but with an interface optimized for still images instead of video.

For WEBP:

brew install webp

The package installs cwebp (encoder), dwebp (decoder), and webpinfo (metadata inspector). All three are from Google’s official project.

To install everything at once and cover both formats:

brew install libavif webp

ImageMagick (brew install imagemagick) and ffmpeg (brew install ffmpeg) are probably already installed on machines belonging to anyone who works with media or development. If they are, the script already detects them as fallbacks without any additional action. Installing them exclusively for image conversion would be disproportionate — they are large packages with dozens of dependencies.

Linux (apt)#

For AVIF:

sudo apt install libavif-bin

The package installs avifenc and avifdec. On distributions based on Debian 12 (Bookworm) and Ubuntu 22.04 or newer, the package is available in the default repositories. On older versions, it may not exist or may contain a very old version of libavif — in that case, ImageMagick with AVIF support (if available) or ffmpeg with libaom-av1 take over via fallback.

For WEBP:

sudo apt install webp

The package installs cwebp, dwebp, and auxiliary tools. It is available on virtually any version of Debian and Ubuntu still in support — it is a stable package that has been in the repositories for years.

Both together:

sudo apt install libavif-bin webp

One difference from macOS: on Linux servers, it is common for ImageMagick to already be installed as a dependency of some web framework (PHP, Rails, Django) but compiled without AVIF support. The convert -list format | grep -i avif that the script uses in detection checks exactly this — the presence of the binary is not enough, the format needs to be in the list of compiled delegates. If the grep does not find AVIF, ImageMagick is discarded and detection moves on to ffmpeg. This is why installing libavif-bin directly is more reliable than depending on ImageMagick for AVIF in server environments.

Integrating with launchd and systemd#

The previous posts explained in detail how WatchPaths and systemd path units work. This section is just a quick reference — the plist and the .path + .service pair ready to copy, without repeating the theory.

The plist (macOS)#

Save to ~/Library/LaunchAgents/com.janio.image-optimizer.plist:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.janio.image-optimizer</string>
    <key>ProgramArguments</key>
    <array>
        <string>/bin/bash</string>
        <string>/Users/janio/bin/optimize-images.sh</string>
    </array>
    <key>WatchPaths</key>
    <array>
        <string>/Users/janio/Pictures/optimize</string>
    </array>
    <key>StandardOutPath</key>
    <string>/Users/janio/.local/log/image-optimizer.log</string>
    <key>StandardErrorPath</key>
    <string>/Users/janio/.local/log/image-optimizer.log</string>
    <key>EnvironmentVariables</key>
    <dict>
        <key>PATH</key>
        <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
    </dict>
</dict>
</plist>

Load and activate:

mkdir -p ~/.local/log
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.janio.image-optimizer.plist

To verify it is running:

launchctl print gui/$(id -u)/com.janio.image-optimizer

To unload if you need to edit the plist:

launchctl bootout gui/$(id -u)/com.janio.image-optimizer

The /opt/homebrew/bin in PATH is where avifenc and cwebp live after installation via Homebrew on Apple Silicon Macs. On Intel Macs, the path would be /usr/local/bin, which is already in the list. The log directory needs to exist before the first execution — launchd does not create it automatically.

The .path + .service (Linux)#

Save to ~/.config/systemd/user/image-optimizer.path:

[Path]
PathChanged=/home/janio/Pictures/optimize

[Install]
WantedBy=default.target

Save to ~/.config/systemd/user/image-optimizer.service:

[Service]
Type=oneshot
ExecStart=/home/janio/bin/optimize-images.sh
Environment=PATH=/usr/local/bin:/usr/bin:/bin

Activate:

systemctl --user daemon-reload
systemctl --user enable --now image-optimizer.path

To check the monitoring status:

systemctl --user status image-optimizer.path

To see the output of recent executions:

journalctl --user -u image-optimizer.service -n 30

The daemon-reload is necessary when files are created or edited — systemd does not detect new units automatically. Logging goes to the journal without any additional configuration, unlike launchd where the log file path needs to be declared in the plist.

On both systems, the workflow from here is the same: save a PNG or JPG image to ~/Pictures/optimize/, wait a few seconds, and verify that the AVIF or WEBP appeared in place of the original. If something does not work, the log (file on macOS, journal on Linux) shows exactly where the process stopped.

Testing before automating#

Plugging the script into launchd or systemd without first confirming that it works in isolation is asking to debug two problems at once — the script and the trigger — without knowing which one is failing. The manual test is quick and eliminates an entire layer of uncertainty.

First, create the directory and copy some test images:

mkdir -p ~/Pictures/optimize
cp some-photo.jpg ~/Pictures/optimize/
cp screenshot.png ~/Pictures/optimize/

Run the script directly:

~/bin/optimize-images.sh

The output should show the detected encoder and the result of each conversion:

2026-03-26 14:32:01 Encoder: avifenc (format: avif)
2026-03-26 14:32:04 Converted: /Users/janio/Pictures/optimize/some-photo.jpg -> /Users/janio/Pictures/optimize/some-photo.avif
2026-03-26 14:32:06 Converted: /Users/janio/Pictures/optimize/screenshot.png -> /Users/janio/Pictures/optimize/screenshot.avif

If Error: no encoder found appears, the message already tells you what to install. If the encoder is detected but conversion fails, the problem is with the parameters or the input image — testing with the encoder directly (avifenc input.png output.avif) isolates the script from the tool.

Comparing sizes confirms that the conversion is producing reasonable results:

ls -lh ~/Pictures/optimize/

A 3 MB JPG that became a 300 KB AVIF is within expectations. An AVIF larger than the original suggests the quality level is too high for that image, or the original was already heavily compressed. Adjusting QUALITY at the top of the script and running again takes seconds.

Also check what happened to the originals. If ORIGINAL_ACTION is delete, the PNGs and JPGs should be gone. If it is move, they should be in ~/Pictures/optimize/originals/. If they are still in place, something failed during conversion and the script did not touch the originals — which is the correct behavior when conversion errors occur.

To test the fallback, temporarily uninstall the primary encoder and run again:

brew uninstall libavif    # macOS
~/bin/optimize-images.sh
brew install libavif      # reinstall afterward

The script should fall back to ImageMagick or ffmpeg and keep working, with the log line showing the substitute encoder. If no encoder is installed at all, the error message with the installation suggestion appears and the script exits without touching any files.

Only after confirming the script works in all these scenarios — normal conversion, fallback, no encoder, empty directory, ORIGINAL_ACTION in both modes — is it worth activating the trigger in the operating system. From that point on, the only test left is to save an image to the monitored directory and check the log to confirm that launchd or systemd fired the script correctly. If the script has already been validated in isolation, any problem at this stage is with the trigger, not the script — and the diagnosis becomes trivial.