Files
pastilhasandClaude Opus 5 cb5dfabce2 the watcher can authenticate, and the HEVC decoder is declared
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>
2026-08-29 07:29:03 +01:00

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