#!/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 HOME_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" STATE="$HOME_DIR/state" CONFIG="$STATE/config.env" # The commander's own credential, written beside this script when the directory was created. Two files # rather than one because they have different lifetimes: `officer.env` is minted by the platform and can # be re-minted without touching what the owner configured, and `config.env` is the owner's answers and # must survive that. Sourcing both is the join. CREDENTIAL="$HOME_DIR/officer.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" # shellcheck disable=SC1090 [[ -f "$CREDENTIAL" ]] && source "$CREDENTIAL" # The platform writes the key as OFFICER_API_KEY. This script asks for OFFICER_AUTH_TOKEN because that # is what a TASK is given, and the two names are the same secret from two directions. Bridged here # rather than renamed in either place: a commander sources the platform's file and a task reads the # platform's env, and neither is this plugin's to rename. : "${OFFICER_AUTH_TOKEN:=${OFFICER_API_KEY:-}}" : "${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