Files
av-club/commanders/convert-on-arrival/watch.sh
T
pastilhasandClaude Opus 5 5b2fe03ffd Convert on arrival — the first plugin-shipped commander
watches a folder and converts what lands in it, so a photo uploaded from a phone is
immediately something a browser can show and a thumbnail can be made from.

configurable, which is the point: watch_dir, extensions, output_format, lossless,
delete_source. only watch_dir has no default, so it is the only thing that blocks
arming.

IT CALLS av-club RATHER THAN ffmpeg OR sharp. everything that makes a conversion
safe lives behind that API, and a watcher shelling out itself would have none of it.

it also breaks the usual commander shape deliberately, and says so. the standard is
"wait, then spawn a headless agent to exercise judgement" — but there is no
judgement in "a HEIC arrived and needs converting", and an agent per photograph
costs real money to reach the same answer every time. so the loop calls the API
directly and only involves an agent when something goes wrong.

three things in the watcher that are not obvious and each cost somebody an
afternoon somewhere:

  close_write and moved_to, never create — `create` fires when a file is opened for
    writing, before it has any bytes
  two identical size readings before touching a file — a file still being copied in
    converts to a truncated result and gets marked done
  a failure is recorded as SEEN — the same file fails identically every tick, and a
    watcher retrying forever is the failure mode that costs money. it is in the log
    and the owner can clear the line.

the brief tells it to offer the Convert Image task for the BACKLOG before arming
anything. a watcher only sees what arrives, and one armed over a quiet folder looks
broken while working perfectly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 02:45:09 +01:00

123 lines
4.8 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# Convert on arrival — the wait.
#
# Started as a BACKGROUND JOB from the commander's own conversation. It does the waiting in the shell,
# which is the whole point: a model-side tick would re-read a conversation to learn that nothing has
# happened, and cost a turn to do it.
#
# It differs from most commander watchers in one way worth knowing. The usual shape is "wait for a
# condition, spawn a headless agent to exercise judgement". There is no judgement here — a HEIC arrived
# and it needs converting — so this calls av-club directly and only involves the agent when something
# goes wrong. An agent per photograph would cost real money to reach the same answer every time.
set -uo pipefail
STATE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/state"
CONFIG="$STATE/config.env"
LOG="$STATE/convert.log"
SEEN="$STATE/converted.list"
[[ -f "$CONFIG" ]] || { echo "No $CONFIG — this commander has not been configured yet."; exit 1; }
# shellcheck disable=SC1090
source "$CONFIG"
: "${WATCH_DIR:?WATCH_DIR is not set}"
: "${EXTENSIONS:=heic,heif}"
: "${OUTPUT_FORMAT:=webp}"
: "${LOSSLESS:=true}"
: "${DELETE_SOURCE:=false}"
: "${OFFICER_API_URL:?OFFICER_API_URL is not set}"
: "${OFFICER_AUTH_TOKEN:?OFFICER_AUTH_TOKEN is not set}"
mkdir -p "$STATE"
touch "$SEEN"
say() { printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG"; }
# The extension test, built once from the config: `heic,heif` becomes `heic|heif`.
EXT_RE="$(echo "$EXTENSIONS" | tr -d ' ' | tr ',' '|')"
matches() {
local lower="${1,,}"
[[ "$lower" =~ \.(${EXT_RE})$ ]]
}
convert_one() {
local input="$1"
local output="${input%.*}.${OUTPUT_FORMAT}"
# Already done, or already there. Both are skips rather than work: the seen-list survives a restart,
# and the output test catches a file somebody converted by hand.
grep -Fxq "$input" "$SEEN" 2>/dev/null && return 0
[[ -e "$output" ]] && { echo "$input" >> "$SEEN"; return 0; }
# A file still being copied in has a size that is still changing. Converting it produces a truncated
# result and marks it done — so wait for two identical readings before touching it.
local a b
a=$(stat -c %s "$input" 2>/dev/null) || return 0
sleep 2
b=$(stat -c %s "$input" 2>/dev/null) || return 0
[[ "$a" == "$b" ]] || { say "still arriving, will retry: $(basename "$input")"; return 0; }
local opts='{"quality":82}'
[[ "$LOSSLESS" == "true" ]] && opts='{"lossless":true}'
local body
body=$(printf '{"inputPath":%s,"outputPath":%s,"pipeline":[{"rotate":{}}],"output":{"format":%s,"options":%s}}' \
"$(printf '%s' "$input" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')" \
"$(printf '%s' "$output" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')" \
"$(printf '"%s"' "$OUTPUT_FORMAT")" "$opts")
local response code
response=$(curl -s -w '\n%{http_code}' -X POST "$OFFICER_API_URL/api/av-club/v1/sharp" \
-H "Authorization: Bearer $OFFICER_AUTH_TOKEN" -H 'content-type: application/json' \
--max-time 300 -d "$body")
code=$(tail -n1 <<<"$response")
if [[ "$code" == "200" ]]; then
say "converted $(basename "$input") → $(basename "$output")"
echo "$input" >> "$SEEN"
if [[ "$DELETE_SOURCE" == "true" ]]; then
rm -f "$input" && say " removed $(basename "$input")"
fi
else
# Recorded as seen even though it failed. A file that cannot be converted will fail identically
# every tick, and a watcher retrying it forever is the one failure mode that costs real money.
# It is in the log, and the owner can clear the line to try again.
say "FAILED $(basename "$input") (HTTP $code): $(head -n-1 <<<"$response" | head -c 200)"
echo "$input" >> "$SEEN"
fi
}
sweep() {
while IFS= read -r -d '' f; do
matches "$f" && convert_one "$f"
done < <(find "$WATCH_DIR" -type f -print0 2>/dev/null)
}
say "watching $WATCH_DIR for .${EXT_RE//|/, .} → $OUTPUT_FORMAT (lossless=$LOSSLESS, delete=$DELETE_SOURCE)"
# One sweep at the start, so a file that landed while this was not running is not missed forever.
# It is not the backlog conversion — everything already present gets marked seen or converted here,
# which is why the brief says to offer the Convert Image task before arming.
sweep
if command -v inotifywait >/dev/null 2>&1; then
say "using inotify"
# `close_write` and `moved_to` are the two ways a finished file appears. `create` is not one of them:
# it fires when the file is opened for writing, before there are any bytes in it.
while true; do
inotifywait -q -r -e close_write -e moved_to --format '%w%f' "$WATCH_DIR" 2>/dev/null | while IFS= read -r f; do
matches "$f" && convert_one "$f"
done
sleep 1
done
else
say "no inotifywait — polling every 60s"
while true; do
sleep 60
sweep
done
fi