diff --git a/commanders/convert-on-arrival/COMMANDER.md b/commanders/convert-on-arrival/COMMANDER.md new file mode 100644 index 0000000..56fa55a --- /dev/null +++ b/commanders/convert-on-arrival/COMMANDER.md @@ -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. diff --git a/commanders/convert-on-arrival/watch.sh b/commanders/convert-on-arrival/watch.sh new file mode 100755 index 0000000..cb493c1 --- /dev/null +++ b/commanders/convert-on-arrival/watch.sh @@ -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