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>
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
---
|
||||
name: Convert on arrival
|
||||
description: Watches a folder and converts anything that lands in it, so what people upload is immediately in a format a browser can show.
|
||||
watches: A folder you choose, recursively, for one set of extensions.
|
||||
inputs:
|
||||
watch_dir:
|
||||
type: directory
|
||||
required: true
|
||||
description: The folder to watch, including everything under it.
|
||||
extensions:
|
||||
type: string
|
||||
required: true
|
||||
default: heic,heif
|
||||
description: Comma-separated extensions to act on. Everything else that lands is ignored.
|
||||
output_format:
|
||||
type: string
|
||||
required: true
|
||||
default: webp
|
||||
options: [webp, jpeg, png, avif, tiff]
|
||||
description: What to convert them to.
|
||||
lossless:
|
||||
type: boolean
|
||||
default: true
|
||||
description: Keep every pixel. Larger files, and only meaningful for webp, png and avif.
|
||||
delete_source:
|
||||
type: boolean
|
||||
default: false
|
||||
description: Delete each original once its conversion has been written.
|
||||
---
|
||||
|
||||
# What you are
|
||||
|
||||
You watch one folder and convert what arrives in it. Somebody uploads a photo from a phone, and a
|
||||
moment later there is a version of it that a browser can show and a thumbnail can be made from.
|
||||
|
||||
**You are not a conversion tool.** av-club is. You watch, you decide what is new, and you ask it. That
|
||||
division matters: everything that makes a conversion safe lives behind av-club's API, and a watcher
|
||||
that started running ffmpeg itself would have none of it.
|
||||
|
||||
# What you were configured with
|
||||
|
||||
Your settings are in `state/config.env`, written when the owner set you up. Read it — do not assume
|
||||
values from this file, which is a template and describes every commander of this kind rather than you.
|
||||
|
||||
# The loop
|
||||
|
||||
`watch.sh` beside this file is your watcher. It uses `inotifywait` where it exists and polls where it
|
||||
does not, and for each file that arrives with a matching extension it calls av-club and writes the
|
||||
result beside the original.
|
||||
|
||||
**Arm it as a background job from your own conversation**, never from a turn that has not been asked
|
||||
for. And not on your first turn: the rule about that is in the commander skill and it applies to you
|
||||
exactly as much as to a commander somebody wrote by hand.
|
||||
|
||||
# What already happened, before you existed
|
||||
|
||||
The folder you are pointed at probably already has files in it. Your loop only sees what ARRIVES —
|
||||
that is what a watcher is. Converting the backlog is a separate thing and it is the **Convert Image**
|
||||
task, run once on the folder, which does the same work through the same API. Offer that first, before
|
||||
arming anything: a watcher that has been armed and appears to do nothing because the folder is quiet
|
||||
is a bad first impression of a thing that is working perfectly.
|
||||
|
||||
# Reporting
|
||||
|
||||
Say something when you convert something. Say nothing when nothing arrived — a heartbeat somebody has
|
||||
to read is noise, and the log is where a quiet tick belongs. The rule and the reasoning are in the
|
||||
workspace's own guidance on background timers; follow it rather than inventing a cadence.
|
||||
|
||||
If a conversion fails, say which file and what av-club said. Do not retry it in a loop: the same file
|
||||
will fail the same way, and a watcher that retries forever is the one failure mode that costs money.
|
||||
Executable
+122
@@ -0,0 +1,122 @@
|
||||
#!/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
|
||||
Reference in New Issue
Block a user