Zum Inhalt

scripts/shoot-appstore-montage.sh

#!/usr/bin/env bash
# Builds a single App Preview MONTAGE per device size: ~4 s of each of the 7 curated
# presets, cut together into one ~28 s clip, transcoded to Apple's exact accepted
# App-Preview resolution. Replaces the single-preset preview.mp4.
#
# Requires the DEBUG boot-preset hook (PHYSARUM_BOOT_PRESET=<index>) in AppState — the
# script relaunches the app once per preset to capture it deterministically. Debug
# simulator builds include the hook; Release excludes it.
#
# Accepted App-Preview sizes (verified 2026-06-21): iPhone 886x1920, iPad 1200x1600, tvOS 1920x1080.
# Output: docs/appstore/app-previews/<device>/preview.mp4 (overwrites the single-preset one).
#
# Requires: Xcode + simulators (6.9" iPhone Pro Max, 13" iPad Pro, Apple TV 4K), ffmpeg.
# 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"
PREV="docs/appstore/app-previews"
NPRESETS=7      # the 7 curated factory slots (indices 0..6)
FORM=7         # seconds to let each preset's pattern form before recording
CLIP=4         # seconds KEPT per preset (ffmpeg -t) → 7 × 4 = 28 s montage
REC=7          # seconds RECORDED per preset; > CLIP so every device captures ≥ CLIP and
               # gets trimmed to exactly CLIP. The tvOS simulator recorder has ~1.7 s
               # startup latency (a 4 s window under-captured), so record long, trim uniform.

command -v ffmpeg >/dev/null || { echo "error: ffmpeg not found (brew install ffmpeg)" >&2; exit 1; }
( cd apple/PhysarumApp && xcodegen generate )

# args: name-pattern, dir-label, sdk, preview-W, preview-H
montage() {
  local sim="$1" dir="$2" sdk="$3" pvW="$4" pvH="$5"
  echo "==> $dir"
  local udid
  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 "skip: no simulator matching /$sim/"; return 0; }
  xcrun simctl boot "$udid" 2>/dev/null || true
  xcrun simctl bootstatus "$udid" -b
  local dd="${TMPDIR:-/tmp}/physarum-montage-$dir"
  xcodebuild -project "$PROJ" -scheme "$SCHEME" -destination "id=$udid" -derivedDataPath "$dd" build >/dev/null
  local app
  app=$(find "$dd/Build/Products" -name 'Physarum.app' -path "*$sdk*" | head -1)
  [ -n "$app" ] || { echo "skip: no .app built for $dir"; return 0; }
  xcrun simctl install "$udid" "$app"

  local tmp="${TMPDIR:-/tmp}/physarum-montage-clips-$dir"
  rm -rf "$tmp"; mkdir -p "$tmp"
  local list="$tmp/list.txt"; : > "$list"

  local i
  for i in $(seq 0 $((NPRESETS - 1))); do
    echo "    preset ${i} of $((NPRESETS - 1))"
    local raw="$tmp/raw-$i.mov" attempt=0 dur=0
    # The simulator recorder sporadically captures nothing (1 frame) on rapid relaunch+record
    # cycles — validate each clip and retry until it has at least CLIP seconds of footage.
    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" &
      local rec=$!
      sleep "$REC"
      kill -INT "$rec" 2>/dev/null || true
      wait "$rec" 2>/dev/null || true
      sleep 2   # let the recorder finish writing + settle before validating / next launch
      dur=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$raw" 2>/dev/null || echo 0)
      if awk "BEGIN{exit !(${dur:-0} >= $CLIP)}"; then break; fi
      echo "      clip $i captured only ${dur}s — retry $attempt"
      [ "$attempt" -ge 4 ] && { echo "      WARNING: clip $i still short after $attempt tries"; break; }
    done
    # Normalise each clip to the exact App-Preview size (cover-scale, no stretch), 30 fps.
    ffmpeg -y -loglevel error -i "$raw" \
      -vf "scale=${pvW}:${pvH}:force_original_aspect_ratio=increase,crop=${pvW}:${pvH},fps=30" \
      -t "$CLIP" -c:v libx264 -profile:v high -pix_fmt yuv420p -an "$tmp/clip-$i.mp4"
    echo "file '$tmp/clip-$i.mp4'" >> "$list"
  done

  mkdir -p "$PREV/$dir"
  # Re-encode the concatenation into one clean file (robust against per-clip param drift).
  ffmpeg -y -loglevel error -f concat -safe 0 -i "$list" \
    -c:v libx264 -profile:v high -pix_fmt yuv420p -movflags +faststart "$PREV/$dir/preview.mp4"
  echo "    wrote $PREV/$dir/preview.mp4 (montage of $NPRESETS presets)"
  ffprobe -v error -select_streams v:0 -show_entries stream=width,height,codec_name -show_entries format=duration \
    -of default=noprint_wrappers=1 "$PREV/$dir/preview.mp4" | sed 's/^/      /'
}

# Optional device filter: pass dir-labels (e.g. `… appletv`) to capture a subset; no args = all.
WANT="${*:-all}"
want() { case " $WANT " in *" all "*|*" $1 "*) return 0 ;; *) return 1 ;; esac; }

want iphone-6.9 && montage "iPhone 1[5-9] Pro Max" "iphone-6.9" "iphonesimulator"   886 1920
want ipad-13    && montage "iPad Pro 13-inch"      "ipad-13"    "iphonesimulator"   1200 1600
want appletv    && montage "Apple TV 4K"           "appletv"    "appletvsimulator"  1920 1080

echo "Done. Montage App previews in $PREV/*/preview.mp4 (review for taste)."