TWO BUGS, one in each half. the watcher demanded OFFICER_AUTH_TOKEN and nothing supplied it. the commander's credential is written to `officer.env` beside the script, under OFFICER_API_KEY — a sibling file this never sourced. so a commander created from this template would have died on its first tick with "OFFICER_AUTH_TOKEN is not set", and I had missed it by appending the values by hand while testing, which is exactly how a thing looks like it works. two files rather than one because they have different lifetimes: officer.env is the platform's and can be re-minted, config.env is the owner's answers and must survive that. the name is bridged in the script rather than renamed at either end — a commander sources the platform's file and a task reads the platform's env, and neither is this plugin's to rename. AND libheif-plugin-libde265 IS NOW DECLARED, which cost an hour to find. libheif packages its CODECS SEPARATELY: `libheif1` alone parses a HEIC container and reports 4032x3024, then fails on the pixels with "Unsupported codec". ubuntu ships the AV1 plugin in the common install and not the HEVC one — which is what every iPhone actually writes. observed today: identical libheif 1.17.6 on two hosts, HEIC decoding on fofos and not on officer-dev, the only difference being that package. a plugin that silently half-works on some machines is worse than one that refuses to install. no dnf entry for imagemagick. fedora's package is literally `ImageMagick` with capitals and the manifest validator refuses uppercase — correctly, since these strings reach a root command line as a closed vocabulary. naming `imagemagick` there would name a package that does not exist. left for the conversation the manifest documentation says a case like this wants. the validator caught that on the way through, and discovery refused the plugin until it was fixed. verified after: no problems, no broken plugins, 3 osDeps read back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
139 lines
5.6 KiB
Bash
Executable File
139 lines
5.6 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
|
|
|
|
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
|