Convertendo imagens automaticamente para WEBP e AVIF

Neste post
Os dois posts anteriores montaram a infraestrutura de monitoramento — WatchPaths no macOS, systemd path units e inotifywait no Linux — e prometeram que os scripts viriam depois. O gatilho está pronto: o launchd ou o systemd detecta quando algo muda num diretório e dispara um comando. Falta o comando.
Este post entrega o script de conversão de imagens que aqueles gatilhos vão disparar. O objetivo é simples: PNGs e JPGs entram numa pasta, WEBP ou AVIF saem. Os originais são apagados ou movidos, dependendo da configuração. O script detecta quais encoders estão disponíveis na máquina e escolhe o melhor entre os instalados, com uma cadeia de fallback que garante funcionamento mesmo quando a ferramenta ideal não está presente. Se nenhum encoder compatível for encontrado, o script avisa o que instalar e em qual gerenciador de pacotes.
O resultado é um script que pode ser usado manualmente (./optimize-images.sh) ou plugado diretamente nos plists e path units dos posts anteriores sem nenhuma adaptação. A ideia é que ele funcione tanto no Mac de quem escreve blog posts quanto no servidor Linux que processa uploads — a mesma lógica, os mesmos fallbacks, os mesmos cuidados.
O problema com converter “depois”#
A conversão de imagens para formatos modernos é uma daquelas tarefas que todo mundo sabe que deveria fazer e quase ninguém faz de forma consistente. As razões são conhecidas: um PNG de 3 MB vira um AVIF de 300 KB com qualidade visual indistinguível; um JPG de câmera com 6 MB cai para menos de 800 KB em WEBP. A diferença não é marginal — é uma ordem de grandeza. Para blogs, portfólios, documentação, e-commerce, qualquer contexto onde imagens são servidas por HTTP, o impacto no tempo de carregamento e no consumo de banda é direto e mensurável.
O problema nunca foi técnico. Ferramentas de conversão existem há anos, tanto gráficas quanto de linha de comando. O Squoosh do Google converte no navegador. O ImageMagick converte qualquer coisa para qualquer coisa. O cwebp e o avifenc são rápidos, gratuitos e instaláveis em uma linha. A barreira é comportamental: cada imagem que passa pelo fluxo de trabalho sem ser convertida é uma decisão que alguém não tomou. Salvar o PNG, abrir o conversor, escolher o formato, ajustar a qualidade, salvar o resultado, apagar o original — são seis passos que competem com tudo mais que a pessoa está fazendo naquele momento. Na segunda semana, o conversor já não é aberto. Na terceira, o blog está servindo PNGs de 4 MB sem que ninguém perceba.
A solução é eliminar a decisão. O script deste post transforma a conversão num processo que acontece sem intervenção: a imagem é salva num diretório, e na próxima vez que o usuário olhar, ela já está em WEBP ou AVIF. Combinado com o monitoramento do launchd ou do systemd, o intervalo entre salvar e converter cai para segundos. O humano cuida do conteúdo; a máquina cuida do formato.
O script#
Configuração#
O bloco de configuração fica no topo do script e concentra todas as decisões que o usuário precisa tomar. São cinco variáveis:
WATCH_DIR="$HOME/Pictures/optimize"
OUTPUT_FORMAT="avif"
QUALITY=80
ORIGINAL_ACTION="delete"
ORIGINALS_DIR="$WATCH_DIR/originals"
OUTPUT_FORMAT aceita avif ou webp. O script valida o valor antes de fazer qualquer coisa e encerra com erro se for algo diferente.
QUALITY é o parâmetro de qualidade passado ao encoder, numa escala de 0 a 100. O valor 80 é um bom ponto de partida para imagens fotográficas — compressão significativa sem artefatos visíveis. Para capturas de tela com texto e bordas definidas, valores entre 85 e 95 preservam melhor a nitidez. O parâmetro é repassado diretamente ao encoder escolhido, e embora a escala seja nominalmente a mesma entre ferramentas, o resultado visual para o mesmo número pode variar ligeiramente entre cwebp, avifenc e ImageMagick. Na prática, a diferença é pequena o suficiente para não justificar tabelas de conversão entre encoders.
ORIGINAL_ACTION controla o que acontece com o arquivo de origem após uma conversão bem-sucedida. Com delete, o original é apagado. Com move, ele é movido para o subdiretório definido em ORIGINALS_DIR — útil para quem quer uma rede de segurança antes de confiar plenamente na qualidade da conversão automática. O diretório de originais é criado automaticamente se não existir.
Detecção de encoders#
Antes de processar qualquer imagem, o script precisa descobrir o que está instalado na máquina. A abordagem é direta: testar a presença de cada encoder com command -v, que retorna sucesso se o binário existe no PATH e falha silenciosamente se não existe.
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
}
A função retorna o nome do encoder escolhido como string, ou uma string vazia se nenhum encoder for encontrado. O chamador verifica o resultado e age de acordo — continua para a conversão ou encerra com uma mensagem de ajuda.
Para o ImageMagick, a detecção tem uma sutileza. Versões mais recentes (7.x) usam o binário magick como ponto de entrada unificado; versões anteriores (6.x) usam convert diretamente. O script testa magick primeiro e cai para convert se necessário. Mas a presença do binário não garante suporte ao formato — uma instalação do ImageMagick compilada sem os delegates certos pode não saber ler ou escrever AVIF ou WEBP. Por isso o teste com convert -list format antes de aceitar o ImageMagick como encoder válido: se o formato não aparece na lista de formatos suportados, o encoder é descartado e a detecção continua para o próximo candidato.
O ffmpeg é o último recurso em ambas as cadeias. É uma ferramenta de vídeo que também converte imagens, mas não é a melhor escolha para o trabalho — os parâmetros são menos intuitivos, a documentação é voltada para fluxos de vídeo, e o controle de qualidade para imagens estáticas é menos refinado. Funciona, mas se o ffmpeg é o único encoder disponível, provavelmente vale a pena instalar a ferramenta dedicada.
A hierarquia de fallback#
A ordem de preferência não é arbitrária. Para cada formato, o encoder dedicado vem primeiro porque é o que oferece melhor controle sobre os parâmetros de compressão e o que produz os melhores resultados para o mesmo nível de qualidade.
Para AVIF, a cadeia é: avifenc → ImageMagick → ffmpeg. O avifenc (do pacote libavif) é a referência — desenvolvido pela Alliance for Open Media, a mesma organização responsável pelo formato. Aceita parâmetros granulares como speed (velocidade de codificação vs. eficiência de compressão) e suporta profundidade de cor de 10 e 12 bits. O ImageMagick delega para o libavif ou para o libaom internamente, então o resultado é comparável, mas os parâmetros de ajuste fino ficam escondidos atrás da interface genérica do convert. O ffmpeg usa o libaom-av1 e funciona, mas a sintaxe para imagens estáticas é desconfortável — o conceito de “um frame de vídeo” como imagem é um encaixe forçado.
Para WEBP, a cadeia é: cwebp → ImageMagick → ffmpeg. O cwebp (do pacote webp do Google) é o encoder de referência, com controle preciso de qualidade, suporte a perfis de compressão lossy e lossless, e saída otimizada para imagens fotográficas. O ImageMagick usa o libwebp internamente e produz resultados equivalentes. O ffmpeg com libwebp funciona mas, novamente, é a opção menos ergonômica.
A função de conversão recebe o nome do encoder e despacha para a sintaxe correta:
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
}
O avifenc tem uma peculiaridade: seu parâmetro de qualidade (cq-level) usa uma escala invertida, onde 0 é a melhor qualidade e 63 é a pior. O script traduz o valor de 0–100 para 63–0 automaticamente, de modo que QUALITY=80 no topo do script significa a mesma coisa independentemente de qual encoder for selecionado. O ffmpeg com libaom-av1 usa o crf na mesma escala invertida e recebe a mesma conversão. Para cwebp, ImageMagick e ffmpeg com libwebp, o valor é passado diretamente — as três ferramentas usam 0–100 onde valores maiores significam melhor qualidade.
Proteção contra arquivos incompletos#
Quando o script é disparado por um WatchPaths ou PathChanged, a execução começa segundos depois de o arquivo aparecer no diretório. Mas “aparecer” não significa “estar completo”. Um navegador salvando uma imagem grande, um aplicativo de edição exportando um PNG de alta resolução, um cp de um volume de rede lento — em todos esses casos, o arquivo é criado (e o gatilho disparado) antes de o conteúdo estar integralmente gravado no disco.
Converter uma imagem parcialmente escrita produz uma de duas coisas: um arquivo de saída corrompido que parece ter metade da imagem, ou um erro do encoder que encerra o script. Nenhum dos dois é aceitável, especialmente se o ORIGINAL_ACTION for delete — apagar o original de um arquivo que não foi convertido com sucesso é perda de dados.
A proteção é um loop que compara o tamanho do arquivo em dois momentos separados por um intervalo curto:
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
}
O stat tem sintaxe diferente no macOS e no Linux — -c%s no GNU coreutils, -f%z no BSD. O script tenta os dois e usa o que funcionar. O loop faz até três tentativas com intervalo de um segundo entre cada comparação. Se após três rodadas o tamanho ainda estiver mudando, a função retorna falha e o script pula aquele arquivo — ele será processado na próxima execução, quando o gatilho disparar novamente após a escrita ser concluída.
O teste "$size_after" != "0" protege contra um edge case sutil: um arquivo que foi criado mas ainda está vazio (tamanho zero em ambas as medições). Isso pode acontecer quando um aplicativo cria o arquivo, abre o file descriptor, mas ainda não começou a escrever. Sem essa verificação, o script consideraria o arquivo estável (o tamanho não mudou) e tentaria converter um arquivo vazio.
Conversão e limpeza#
O loop principal varre o diretório, processa cada arquivo elegível e cuida dos originais:
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
}
O find com -maxdepth 1 evita descer em subdiretórios — inclusive no originals/, o que seria desastroso se os originais movidos fossem reprocessados. O -iname torna a busca case-insensitive, capturando tanto .PNG quanto .png e .Jpg. O while IFS= read -r em vez de for file in $(find ...) trata corretamente nomes de arquivo com espaços.
Quando a conversão falha, o rm -f "$output" apaga qualquer arquivo de saída parcial que o encoder tenha criado antes de abortar. Deixar um AVIF corrompido no diretório causaria confusão — pareceria que a conversão foi bem-sucedida, mas a imagem estaria quebrada.
A função handle_original encapsula a decisão configurada em 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
}
A última peça é a função que sugere a instalação quando nenhum encoder é encontrado:
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
}
A detecção de sistema usa uname — Darwin para macOS, qualquer outra coisa assume Linux com apt. É uma simplificação deliberada: o script não tenta detectar se o sistema usa dnf, pacman ou apk. Quem usa Fedora ou Arch sabe encontrar o pacote equivalente; quem usa Debian, Ubuntu ou qualquer derivado — que é a grande maioria dos servidores Linux em produção — recebe a sugestão correta.
O script completo#
Cada função foi explicada isoladamente nas seções anteriores. Aqui está o script montado, pronto para salvar em ~/bin/optimize-images.sh e tornar executável com 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
Salvar, tornar executável e testar manualmente antes de plugar no launchd ou no systemd:
chmod +x ~/bin/optimize-images.sh
cp alguma-foto.jpg ~/Pictures/optimize/
~/bin/optimize-images.sh
ls -lh ~/Pictures/optimize/
O ls -lh confirma que o AVIF (ou WEBP) foi criado e mostra o tamanho comparado. Se tudo funcionou, o arquivo original sumiu (ou foi movido para originals/, dependendo da configuração). A partir daqui, basta ativar o gatilho no sistema operacional — que é o assunto da próxima seção.
Instalando os encoders#
O script funciona com qualquer encoder da cadeia de fallback, mas o resultado ideal vem das ferramentas dedicadas — avifenc para AVIF e cwebp para WEBP. São as que oferecem melhor controle de qualidade, melhor compressão para o mesmo nível visual, e os parâmetros mais previsíveis. O ImageMagick e o ffmpeg funcionam como rede de segurança, não como primeira escolha.
macOS (Homebrew)#
Para AVIF:
brew install libavif
O pacote instala o avifenc (encoder) e o avifdec (decoder). O encoder é compilado com suporte a libaom, que é a implementação de referência do AV1 — a mesma que o ffmpeg usaria, mas com uma interface otimizada para imagens estáticas em vez de vídeo.
Para WEBP:
brew install webp
O pacote instala o cwebp (encoder), o dwebp (decoder) e o webpinfo (inspetor de metadados). Os três são do projeto oficial do Google.
Para quem quer instalar tudo de uma vez e cobrir os dois formatos:
brew install libavif webp
O ImageMagick (brew install imagemagick) e o ffmpeg (brew install ffmpeg) provavelmente já estão instalados em máquinas de quem trabalha com mídia ou desenvolvimento. Se estiverem, o script já os detecta como fallback sem nenhuma ação adicional. Instalá-los exclusivamente para conversão de imagens seria desproporcional — são pacotes grandes com dezenas de dependências.
Linux (apt)#
Para AVIF:
sudo apt install libavif-bin
O pacote instala o avifenc e o avifdec. Em distribuições baseadas em Debian 12 (Bookworm) e Ubuntu 22.04 ou mais recentes, o pacote está disponível nos repositórios padrão. Em versões anteriores, pode não existir ou conter uma versão muito antiga do libavif — nesse caso, o ImageMagick com suporte a AVIF (se disponível) ou o ffmpeg com libaom-av1 assumem via fallback.
Para WEBP:
sudo apt install webp
O pacote instala o cwebp, o dwebp e ferramentas auxiliares. Está disponível em praticamente qualquer versão do Debian e Ubuntu ainda em suporte — é um pacote estável e presente nos repositórios há anos.
Os dois juntos:
sudo apt install libavif-bin webp
Uma diferença em relação ao macOS: em servidores Linux, é comum o ImageMagick já estar instalado como dependência de algum framework web (PHP, Rails, Django) mas compilado sem suporte a AVIF. O convert -list format | grep -i avif que o script usa na detecção verifica exatamente isso — a presença do binário não basta, o formato precisa estar na lista de delegates compilados. Se o grep não encontrar AVIF, o ImageMagick é descartado e a detecção segue para o ffmpeg. É por isso que instalar o libavif-bin diretamente é mais confiável do que depender do ImageMagick para AVIF em ambientes de servidor.
Integrando com launchd e systemd#
Os posts anteriores explicaram em detalhe como o WatchPaths e os systemd path units funcionam. Esta seção é apenas referência rápida — o plist e o par .path + .service prontos para copiar, sem repetir a teoria.
O plist (macOS)#
Salvar em ~/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>
Carregar e ativar:
mkdir -p ~/.local/log
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.janio.image-optimizer.plist
Para verificar que está rodando:
launchctl print gui/$(id -u)/com.janio.image-optimizer
Para descarregar se precisar editar o plist:
launchctl bootout gui/$(id -u)/com.janio.image-optimizer
O /opt/homebrew/bin no PATH é onde o avifenc e o cwebp moram após instalação via Homebrew em Macs com Apple Silicon. Em Macs Intel, o caminho seria /usr/local/bin, que já está na lista. O diretório de log precisa existir antes da primeira execução — o launchd não o cria automaticamente.
O .path + .service (Linux)#
Salvar em ~/.config/systemd/user/image-optimizer.path:
[Path]
PathChanged=/home/janio/Pictures/optimize
[Install]
WantedBy=default.target
Salvar em ~/.config/systemd/user/image-optimizer.service:
[Service]
Type=oneshot
ExecStart=/home/janio/bin/optimize-images.sh
Environment=PATH=/usr/local/bin:/usr/bin:/bin
Ativar:
systemctl --user daemon-reload
systemctl --user enable --now image-optimizer.path
Para verificar o estado do monitoramento:
systemctl --user status image-optimizer.path
Para ver a saída das últimas execuções:
journalctl --user -u image-optimizer.service -n 30
O daemon-reload é necessário quando os arquivos são criados ou editados — o systemd não detecta units novas automaticamente. O logging vai para o journal sem nenhuma configuração adicional, ao contrário do launchd onde o caminho do arquivo de log precisa ser declarado no plist.
Em ambos os sistemas, o fluxo a partir daqui é o mesmo: salvar uma imagem PNG ou JPG em ~/Pictures/optimize/, esperar alguns segundos, e verificar que o AVIF ou WEBP apareceu no lugar do original. Se algo não funcionar, o log (arquivo no macOS, journal no Linux) mostra exatamente onde o processo parou.
Testando antes de automatizar#
Plugar o script no launchd ou no systemd sem antes confirmar que ele funciona isoladamente é pedir para debugar dois problemas ao mesmo tempo — o script e o gatilho — sem saber qual dos dois está falhando. O teste manual é rápido e elimina uma camada inteira de incerteza.
Primeiro, criar o diretório e copiar algumas imagens de teste:
mkdir -p ~/Pictures/optimize
cp alguma-foto.jpg ~/Pictures/optimize/
cp captura-de-tela.png ~/Pictures/optimize/
Rodar o script diretamente:
~/bin/optimize-images.sh
A saída deve mostrar o encoder detectado e o resultado de cada conversão:
2026-03-26 14:32:01 Encoder: avifenc (format: avif)
2026-03-26 14:32:04 Converted: /Users/janio/Pictures/optimize/alguma-foto.jpg -> /Users/janio/Pictures/optimize/alguma-foto.avif
2026-03-26 14:32:06 Converted: /Users/janio/Pictures/optimize/captura-de-tela.png -> /Users/janio/Pictures/optimize/captura-de-tela.avif
Se aparecer Error: no encoder found, a mensagem já diz o que instalar. Se o encoder for detectado mas a conversão falhar, o problema está nos parâmetros ou na imagem de entrada — testar com o encoder diretamente (avifenc input.png output.avif) isola o script da ferramenta.
Comparar os tamanhos confirma que a conversão está produzindo resultados razoáveis:
ls -lh ~/Pictures/optimize/
Um JPG de 3 MB que virou um AVIF de 300 KB está dentro do esperado. Um AVIF maior que o original sugere que o nível de qualidade está alto demais para aquela imagem, ou que o original já estava bastante comprimido. Ajustar o QUALITY no topo do script e rodar de novo é questão de segundos.
Verificar também o que aconteceu com os originais. Se ORIGINAL_ACTION é delete, os PNGs e JPGs devem ter sumido. Se é move, devem estar em ~/Pictures/optimize/originals/. Se ainda estão no lugar, algo falhou na conversão e o script não tocou nos originais — que é o comportamento correto quando a conversão dá erro.
Para testar o fallback, desinstalar temporariamente o encoder principal e rodar de novo:
brew uninstall libavif # macOS
~/bin/optimize-images.sh
brew install libavif # reinstala depois
O script deve cair para o ImageMagick ou ffmpeg e continuar funcionando, com a linha de log mostrando o encoder substituto. Se nenhum encoder estiver instalado, a mensagem de erro com a sugestão de instalação aparece e o script encerra sem tocar em nenhum arquivo.
Só depois de confirmar que o script funciona em todos esses cenários — conversão normal, fallback, nenhum encoder, diretório vazio, ORIGINAL_ACTION em ambos os modos — vale a pena ativar o gatilho no sistema operacional. A partir daí, o único teste que resta é salvar uma imagem no diretório monitorado e verificar o log para confirmar que o launchd ou o systemd disparou o script corretamente. Se o script já foi validado isoladamente, qualquer problema nessa etapa é do gatilho, não do script — e o diagnóstico fica trivial.
Sysadmin, 53 anos, brasileiro trabalhando de casa para o mundo todo. Cuida de servidores Linux, containers LXC, e de gatos que não saem de cima do teclado.