#!/usr/bin/env bash
# Records the 7 curated presets on the Apple TV simulator and cross-dissolves them into
# one seamless ~2-minute Top Shelf hero loop (4K master). Output goes to public/topshelf/
# so the pages deploy serves it for the Top Shelf extension's previewVideoURL.
#
# Reuses the PHYSARUM_BOOT_PRESET DEBUG hook (deterministic preset boot). ffmpeg required.
# Run ONLY when no other xcodebuild is active (concurrent builds on the same project race).
set -euo pipefail
cd "$(dirname "$0")/.."
SCHEME="Physarum"
PROJ="apple/PhysarumApp/Physarum.xcodeproj"
BUNDLE_ID="de.jkaindl.physarum"
OUT="public/topshelf"
NPRESETS=7
FORM=7 # seconds to let each preset's pattern form before recording
REC=22 # seconds recorded per preset (long enough for the kept window + retries)
CLIP=18 # seconds KEPT per preset (ffmpeg -t)
XF=2 # cross-dissolve duration between presets
MINSAT=3 # min mean chroma (signalstats SATAVG) a kept clip must show — see content check below
command -v ffmpeg >/dev/null || { echo "error: ffmpeg not found (brew install ffmpeg)" >&2; exit 1; }
# Content check: is this recording actually the running physarum, not the tvOS home screen?
# A failed launch/foreground leaves the recorder capturing the Apple TV home screen or
# Settings app — which is a near-grayscale UI (dark gradient + a desaturated icon/dock),
# whereas physarum is vivid, highly-saturated colour edge to edge. So we measure mean
# chroma (signalstats SATAVG): physarum frames score ~15-60, the home screen ~0.2. We
# sample one frame per second across the kept window and require the mean to clear MINSAT.
# Prints the mean SATAVG to stdout. (A duration-only check passes a full-length home-screen
# capture — see review of Task 1 — so this is the real "is it physarum" gate.)
clip_satavg() {
local file="$1" dur="$2"
ffmpeg -hide_banner -nostats -t "$dur" -i "$file" \
-vf "fps=1,signalstats,metadata=print:key=lavfi.signalstats.SATAVG" -f null - 2>&1 \
| grep -oE 'SATAVG=[0-9.]+' | sed 's/SATAVG=//' \
| LC_ALL=C awk '{s+=$1; n++} END{ if (n>0) printf "%.3f", s/n; else print "0" }'
}
# SATAVG of a single still image. Must NOT use fps=1/-t — those drop the lone frame of a
# 1-frame input on ffmpeg 8.x, yielding 0 and falsely rejecting every poster candidate.
still_satavg() {
local v
v=$(ffmpeg -hide_banner -nostats -i "$1" \
-vf "signalstats,metadata=print:key=lavfi.signalstats.SATAVG" -f null - 2>&1 \
| grep -oE 'SATAVG=[0-9.]+' | head -1 | sed 's/SATAVG=//')
echo "${v:-0}"
}
( cd apple/PhysarumApp && xcodegen generate )
SIM="Apple TV 4K"
udid=$(xcrun simctl list devices available | grep -E "$SIM" | grep -oiE '[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}' | head -1 || true)
[ -n "$udid" ] || { echo "error: no '$SIM' simulator found" >&2; exit 1; }
xcrun simctl boot "$udid" 2>/dev/null || true
xcrun simctl bootstatus "$udid" -b
dd="${TMPDIR:-/tmp}/physarum-topshelf-dd"
xcodebuild -project "$PROJ" -scheme "$SCHEME" -destination "id=$udid" -derivedDataPath "$dd" build >/dev/null
app=$(find "$dd/Build/Products" -name 'Physarum.app' -path '*appletvsimulator*' | head -1)
[ -n "$app" ] || { echo "error: no tvOS .app built" >&2; exit 1; }
xcrun simctl install "$udid" "$app"
tmp="${TMPDIR:-/tmp}/physarum-topshelf-clips"; rm -rf "$tmp"; mkdir -p "$tmp"
for i in $(seq 0 $((NPRESETS - 1))); do
echo "==> preset ${i} of $((NPRESETS - 1))"
raw="$tmp/raw-$i.mov"; attempt=0
while :; do
attempt=$((attempt + 1))
SIMCTL_CHILD_PHYSARUM_BOOT_PRESET="$i" xcrun simctl launch --terminate-running-process "$udid" "$BUNDLE_ID" >/dev/null
sleep "$FORM"
xcrun simctl io "$udid" recordVideo --codec=h264 --force "$raw" &
rec=$!; sleep "$REC"; kill -INT "$rec" 2>/dev/null || true; wait "$rec" 2>/dev/null || true
sleep 2
dur=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$raw" 2>/dev/null || echo 0)
# Gate 1: enough footage. Gate 2: it's actually physarum, not the tvOS home screen.
if ! awk "BEGIN{exit !(${dur:-0} >= $CLIP)}"; then
echo " clip $i short (${dur}s), retry $attempt"; [ "$attempt" -ge 4 ] && break; continue
fi
sat=$(clip_satavg "$raw" "$CLIP")
if awk "BEGIN{exit !(${sat:-0} >= $MINSAT)}"; then
echo " clip $i OK (${dur}s, SATAVG ${sat})"; break
fi
echo " clip $i is the home screen, not physarum (SATAVG ${sat} < $MINSAT), retry $attempt"
[ "$attempt" -ge 6 ] && { echo " WARNING: clip $i never showed physarum after $attempt tries"; break; }
done
# Normalise to 1920x1080 (the carousel video size; the 4K ATV upscales — a 4K/no-cap master
# ballooned to ~350 MB), trim to CLIP, 30 fps.
ffmpeg -y -loglevel error -i "$raw" \
-vf "scale=1920:1080:force_original_aspect_ratio=increase,crop=1920:1080,fps=30" \
-t "$CLIP" -c:v libx264 -profile:v high -pix_fmt yuv420p -an "$tmp/clip-$i.mp4"
done
# Build the xfade chain: each xfade offset = (previous output duration) - XF; output grows by (CLIP - XF) each step.
inputs=(); for i in $(seq 0 $((NPRESETS - 1))); do inputs+=(-i "$tmp/clip-$i.mp4"); done
filter=""; prev="0"; off=$((CLIP - XF))
filter="[0][1]xfade=transition=dissolve:duration=${XF}:offset=${off}[x1]"
acc=$((CLIP + (CLIP - XF))) # duration after first xfade = CLIP + (CLIP - XF)
for i in $(seq 2 $((NPRESETS - 1))); do
off=$((acc - XF))
filter="${filter};[x$((i-1))][$i]xfade=transition=dissolve:duration=${XF}:offset=${off}[x$i]"
acc=$((acc + (CLIP - XF)))
done
mkdir -p "$OUT"
# CRF + a VBV cap keep the committed/hosted asset to a sane size (~20-35 MB for ~2 min 1080p).
ffmpeg -y -loglevel error "${inputs[@]}" -filter_complex "${filter}" -map "[x$((NPRESETS-1))]" \
-c:v libx264 -profile:v high -crf 28 -maxrate 3M -bufsize 6M -pix_fmt yuv420p -movflags +faststart "$OUT/hero.mp4"
# Poster = a well-formed, vivid physarum frame. Candidate offsets sit at preset CENTRES
# (8 + i*(CLIP-XF)), away from the xfade transitions AND away from any clip that may have
# slipped through as the home screen. We pick the FIRST candidate whose frame clears MINSAT,
# so the Top Shelf carousel image can never be a grayscale Apple TV Settings screenshot
# (the original -ss 40 landed inside a defective home-screen segment — see review of Task 1).
poster_made=""
for ss in 24 56 8 72 88 104 40; do
ffmpeg -y -loglevel error -ss "$ss" -i "$OUT/hero.mp4" -frames:v 1 "$OUT/poster.png"
psat=$(still_satavg "$OUT/poster.png")
if awk "BEGIN{exit !(${psat:-0} >= $MINSAT)}"; then
echo "Poster from ${ss}s (SATAVG ${psat})."; poster_made=1; break
fi
echo " poster candidate ${ss}s too grey (SATAVG ${psat}), trying next"
done
[ -n "$poster_made" ] || { echo "error: no vivid poster frame found — hero.mp4 may still contain home-screen segments" >&2; exit 1; }
echo "Done."
ffprobe -v error -select_streams v:0 -show_entries stream=width,height -show_entries format=duration \
-of default=noprint_wrappers=1 "$OUT/hero.mp4" | sed 's/^/ /'