Zum Inhalt

tvOS Top Shelf Hero Video Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Steps use checkbox (- [ ]) syntax.

Goal: Give the Apple TV app a cinematic ~2-minute Top Shelf hero loop — living physarum cross-dissolving through the 7 curated presets, played full-screen on the home screen via a new tvOS Top Shelf extension.

Architecture: A capture script reuses the PHYSARUM_BOOT_PRESET DEBUG hook to record the 7 presets, then ffmpeg xfade-chains them into one seamless loop; the video + poster are hosted on Codeberg Pages (via public/topshelf/). A new tvOS-only PhysarumTopShelf app-extension target runs a TVTopShelfContentProvider that returns a one-item TVTopShelfCarouselContent pointing at the hosted URLs. The hosted-URL config is a pure cross-platform value type (host-tested); the TVServices mapping is tvOS-only (build-verified); the real top-shelf display is a device sign-off.

Tech Stack: ffmpeg (xfade/HLS), xcrun simctl (capture), XcodeGen (extension target), Swift TVServices (tvOS), Vite/public/ → Codeberg Pages hosting.

Spec: docs/superpowers/specs/2026-06-21-tvos-topshelf-hero-design.md

The 7 curated preset indices: 0..6 (aurora, dancing-ink, mandala, mystic, neon-waves, solar-veil, terminal-haze).


Conventions

  • Repo root: the physarum checkout (all paths repo-relative); app dir apple/PhysarumApp. Work on main. Conventional Commits, no emoji, no --no-verify. Do NOT push (gated).
  • Build macOS: cd apple/PhysarumApp && xcodegen generate && xcodebuild -scheme Physarum -destination 'platform=macOS' build. iOS/tvOS: -destination 'generic/platform=iOS Simulator' / 'generic/platform=tvOS Simulator'. Host tests: xcodebuild test -scheme Physarum -destination 'platform=macOS' (target PhysarumAppTests).
  • Honest testing note: TVServices is tvOS-only, so the content-provider cannot run in the macOS host suite. Host-test the pure URL config; build-verify the provider on tvOS; the real top-shelf display is Johannes' device sign-off.

Task 1: Hero-video capture + cross-dissolve loop script

Files: - Create: scripts/shoot-topshelf-hero.sh (executable) - Output: public/topshelf/hero.mp4 + public/topshelf/poster.png

Reuses the montage script's proven structure (version-robust sim matching, the PHYSARUM_BOOT_PRESET hook, the validate-and-retry recorder). The new part is ffmpeg xfade-chaining into one seamless loop instead of hard-cut concat.

  • [ ] Step 1: Write the script

Create scripts/shoot-topshelf-hero.sh:

#!/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 Codeberg 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

command -v ffmpeg >/dev/null || { echo "error: ffmpeg not found (brew install ffmpeg)" >&2; exit 1; }
( 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)
    if awk "BEGIN{exit !(${dur:-0} >= $CLIP)}"; then break; fi
    echo "    clip $i short (${dur}s), retry $attempt"; [ "$attempt" -ge 4 ] && break
  done
  # Normalise to 3840x2160, trim to CLIP, 30 fps.
  ffmpeg -y -loglevel error -i "$raw" \
    -vf "scale=3840:2160:force_original_aspect_ratio=increase,crop=3840:2160,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"
ffmpeg -y -loglevel error "${inputs[@]}" -filter_complex "${filter}" -map "[x$((NPRESETS-1))]" \
  -c:v libx264 -profile:v high -pix_fmt yuv420p -movflags +faststart "$OUT/hero.mp4"

# Poster = a well-formed frame from the middle of the loop.
ffmpeg -y -loglevel error -ss 40 -i "$OUT/hero.mp4" -frames:v 1 "$OUT/poster.png"

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/^/  /'
  • [ ] Step 2: Syntax-check

Run: chmod +x scripts/shoot-topshelf-hero.sh && bash -n scripts/shoot-topshelf-hero.sh && echo "syntax OK" Expected: syntax OK.

  • [ ] Step 3: Run it (produces the hero loop)

Run: ./scripts/shoot-topshelf-hero.sh Expected: public/topshelf/hero.mp4 (3840×2160, ~110-115 s) + public/topshelf/poster.png (3840×2160). The final ffprobe line prints width=3840 height=2160 and a duration around 108-116 s. If the duration is far off, the xfade offset math needs adjusting (iterate).

  • [ ] Step 4: Verify the dissolve looks smooth (frame spot-check)

Run (extract a frame at a cross-dissolve boundary, ~16 s = first transition):

ffmpeg -y -loglevel error -ss 16 -i public/topshelf/hero.mp4 -frames:v 1 "${TMPDIR:-/tmp}/xf.png"
echo "wrote ${TMPDIR:-/tmp}/xf.png — open it; you should see two presets blended (a dissolve), not a hard cut"
Expected: a blended frame (two presets overlapping) confirming the cross-dissolve. (The controller dispatching this plan will eyeball it.)

  • [ ] Step 5: Commit
git add scripts/shoot-topshelf-hero.sh public/topshelf/hero.mp4 public/topshelf/poster.png
git commit -m "feat(tvos): Top Shelf hero loop capture script + 2min cross-dissolve video"

Task 2: Hosted-URL config (pure, host-tested)

Files: - Create: apple/PhysarumApp/Sources/TV/TopShelfHero.swift - Test: apple/PhysarumApp/Tests/TopShelfHeroTests.swift

A Foundation-only value type holding the identifier + the Codeberg Pages URLs. Cross-platform (no TVServices), so it IS host-testable. Added to BOTH the app target and the extension target (Task 3) so the provider reuses it.

  • [ ] Step 1: Write the failing test

Create apple/PhysarumApp/Tests/TopShelfHeroTests.swift:

import XCTest
@testable import Physarum

final class TopShelfHeroTests: XCTestCase {
    func testHeroConfigIsValid() {
        let h = TopShelfHero.shared
        XCTAssertEqual(h.identifier, "physarum-hero")
        XCTAssertEqual(h.videoURL.scheme, "https")
        XCTAssertEqual(h.posterURL.scheme, "https")
        XCTAssertTrue(h.videoURL.absoluteString.hasSuffix("/topshelf/hero.mp4"))
        XCTAssertTrue(h.posterURL.absoluteString.hasSuffix("/topshelf/poster.png"))
        XCTAssertEqual(h.videoURL.host, "jkaindl.codeberg.page")
    }
}
  • [ ] Step 2: Run test to verify it fails

Run: cd apple/PhysarumApp && xcodebuild test -scheme Physarum -destination 'platform=macOS' -only-testing:PhysarumAppTests/TopShelfHeroTests Expected: FAIL — cannot find 'TopShelfHero' in scope.

  • [ ] Step 3: Implement

Create apple/PhysarumApp/Sources/TV/TopShelfHero.swift:

import Foundation

/// The hosted Top Shelf hero asset (served from Codeberg Pages by the web deploy).
/// Pure value type — no TVServices — so it's shared by the app + the Top Shelf extension
/// and is host-testable. The TVServices mapping lives in TopShelfProvider (tvOS-only).
struct TopShelfHero {
    let identifier: String
    let videoURL: URL
    let posterURL: URL

    static let shared = TopShelfHero(
        identifier: "physarum-hero",
        videoURL: URL(string: "https://pages.jkaindl.de/physarum/topshelf/hero.mp4")!,
        posterURL: URL(string: "https://pages.jkaindl.de/physarum/topshelf/poster.png")!
    )
}
  • [ ] Step 4: Run test to verify it passes

Run: xcodebuild test -scheme Physarum -destination 'platform=macOS' -only-testing:PhysarumAppTests/TopShelfHeroTests Expected: PASS.

  • [ ] Step 5: Commit
git add apple/PhysarumApp/Sources/TV/TopShelfHero.swift apple/PhysarumApp/Tests/TopShelfHeroTests.swift
git commit -m "feat(tvos): TopShelfHero hosted-asset config (host-tested)"

Task 3: Top Shelf extension target + content provider

Files: - Create: apple/PhysarumApp/TopShelf/TopShelfProvider.swift (a SIBLING of Sources/, not under it) - Create: apple/PhysarumApp/TopShelf/Info.plist - Modify: apple/PhysarumApp/project.yml (add the PhysarumTopShelf target + embed it in the tvOS app)

This is the fiddly task: a tvOS-only app extension embedded into a multi-platform app. The gate is all three platform builds stay green. The API names below are best-known for the tvOS 17 SDK — VERIFY against the SDK (Xcode autocomplete / build errors) and adjust; the build is the source of truth.

Why TopShelf/ is a sibling of Sources/, not inside it: the app target uses sources: - path: Sources (recursive), so anything under Sources/ compiles into the app on all platforms. TopShelfProvider.swift imports TVServices (tvOS-only) — if it lived under Sources/, the macOS/iOS app build would fail. Keeping it in a sibling TopShelf/ dir means only the extension target (which explicitly lists TopShelf) compiles it. The shared TopShelfHero.swift stays under Sources/ (Foundation-only, compiles everywhere, host-testable) and is referenced by both targets.

  • [ ] Step 1: Write the content provider

Create apple/PhysarumApp/TopShelf/TopShelfProvider.swift:

import TVServices

/// Returns a single full-screen carousel hero (the hosted physarum loop) for the Top Shelf.
/// The previewVideoURL plays after the poster image transitions in. tvOS-only (TVServices).
final class TopShelfProvider: TVTopShelfContentProvider {
    override func loadTopShelfContent(completionHandler: @escaping (TVTopShelfContent?) -> Void) {
        let hero = TopShelfHero.shared
        let item = TVTopShelfCarouselItem(identifier: hero.identifier)
        item.imageShape = .hdtv
        item.setImageURL(hero.posterURL, traits: [])
        item.previewVideoURL = hero.videoURL
        if let open = URL(string: "physarum://open") {
            let action = TVTopShelfAction(url: open)
            item.displayAction = action
            item.playAction = action
        }
        let content = TVTopShelfCarouselContent(style: .details, items: [item])
        completionHandler(content)
    }
}

SDK-verify: TVTopShelfImageShape case (.hdtv), setImageURL(_:traits:) signature + the empty-traits value, and TVTopShelfCarouselContent.Style case (.details). If a name differs, fix it until the tvOS build is green.

  • [ ] Step 2: Write the extension Info.plist

Create apple/PhysarumApp/TopShelf/Info.plist:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>CFBundleDisplayName</key>
  <string>Physarum Top Shelf</string>
  <key>NSExtension</key>
  <dict>
    <key>NSExtensionPointIdentifier</key>
    <string>com.apple.tv-top-shelf</string>
    <key>NSExtensionPrincipalClass</key>
    <string>$(PRODUCT_MODULE_NAME).TopShelfProvider</string>
  </dict>
</dict>
</plist>
  • [ ] Step 3: Add the extension target + embed in the tvOS app (project.yml)

In apple/PhysarumApp/project.yml, under targets:, add a new target:

  PhysarumTopShelf:
    type: tv-app-extension
    platform: tvOS
    deploymentTarget: "17.0"
    sources:
      - path: TopShelf                         # sibling of Sources/ — extension-only (TVServices)
      - path: Sources/TV/TopShelfHero.swift    # shared pure config (also in the app target)
    info:
      path: TopShelf/Info.plist
    settings:
      base:
        PRODUCT_BUNDLE_IDENTIFIER: de.jkaindl.physarum.topshelf
        CODE_SIGN_STYLE: Automatic
        "CODE_SIGN_STYLE[sdk=appletvsimulator*]": Manual
        "CODE_SIGN_IDENTITY[sdk=appletvsimulator*]": "-"
        "CODE_SIGNING_REQUIRED[sdk=appletvsimulator*]": "NO"

Then embed it in the app only for tvOS — add to the Physarum app target's dependencies::

      - target: PhysarumTopShelf
        embed: true
        platformFilter: tvOS

If platformFilter is not honoured by this XcodeGen version (the macOS/iOS build then fails trying to embed a tvOS extension), fall back to: keep the dependency unconditional but set the extension target's supportedDestinations: [tvOS], and if the multi-platform embed still breaks macOS/iOS, gate the embed by generating per-platform — iterate until all three builds are green. Document whatever worked in a comment in project.yml.

  • [ ] Step 4: Regenerate + build all three platforms (the real gate)

Run:

cd apple/PhysarumApp && xcodegen generate \
 && xcodebuild -scheme Physarum -destination 'generic/platform=tvOS Simulator' build \
 && xcodebuild -scheme Physarum -destination 'platform=macOS' build \
 && xcodebuild -scheme Physarum -destination 'generic/platform=iOS Simulator' build
Expected: ** BUILD SUCCEEDED ** for all three. The tvOS app bundle now embeds the PhysarumTopShelf.appex. If macOS/iOS fail on the embed, iterate Step 3 until green (this is the known-fiddly part).

  • [ ] Step 5: Confirm the extension is embedded in the tvOS app

Run:

APP=$(find "${TMPDIR:-/tmp}" ~/Library/Developer/Xcode/DerivedData -name 'Physarum.app' -path '*appletvsimulator*' 2>/dev/null | head -1)
ls "$APP/PlugIns/" 2>/dev/null | grep -i topshelf || find "$APP" -name '*.appex'
Expected: PhysarumTopShelf.appex present in the tvOS app's PlugIns.

  • [ ] Step 6: Run the full host suite (no regression)

Run: xcodebuild test -scheme Physarum -destination 'platform=macOS' Expected: ** TEST SUCCEEDED ** (incl. TopShelfHeroTests).

  • [ ] Step 7: Commit
git add apple/PhysarumApp/TopShelf apple/PhysarumApp/project.yml
git commit -m "feat(tvos): Top Shelf extension target + carousel content provider"

Task 4: Device sign-off step + final regression

Files: - Modify: $VAULT/25_Coding/physarum/Handover-AppStore.md (maintainer's Obsidian vault; add a tvOS Top Shelf sign-off step)

  • [ ] Step 1: Add the device sign-off to the handover note

In Handover-AppStore.md, add under the device-sign-off section a new checkbox group (the top shelf only shows on a real ATV, with Physarum in the top home-screen row, AND after the hosted video has deployed):

### C · tvOS Top Shelf hero (nach Push + Deploy)

- [ ] **C1 — Physarum in die oberste Home-Reihe.** Auf der ATV das Physarum-Icon in die **oberste Reihe** schieben und fokussieren.
  - Erwartet: oben erscheint der **Hero-Loop** (lebendes Physarum, weich durch die Presets), full-screen.
  - **Ampel:** [ ] 🟢 spielt + sieht edel aus · [ ] 🟡 nur das Poster, kein Video → Terminal (ggf. .mp4→HLS) · [ ] 🔴 nichts → Terminal
- [ ] **C2 — 🛑 Zurück ans Terminal.** Sag „Top Shelf läuft" oder droppe das Verhalten.
  • [ ] Step 2: Final regression — all platforms + suites green

Run:

cd apple/PhysarumApp && xcodegen generate \
 && xcodebuild -scheme Physarum -destination 'generic/platform=tvOS Simulator' build \
 && xcodebuild -scheme Physarum -destination 'platform=macOS' build \
 && xcodebuild -scheme Physarum -destination 'generic/platform=iOS Simulator' build \
 && xcodebuild test -scheme Physarum -destination 'platform=macOS'
Expected: three ** BUILD SUCCEEDED ** + ** TEST SUCCEEDED **.

  • [ ] Step 3: Confirm the web is unaffected (the hero just rides along in public/)

Run: cd "$(git rev-parse --show-toplevel)" && npm run build 2>&1 | tail -3 Expected: build succeeds; dist/topshelf/hero.mp4 + dist/topshelf/poster.png exist (Vite copied public/topshelf/).

  • [ ] Step 4: Done — verification only (the handover edit is a vault file, committed at clean-shutdown, not here). Report any gate failure with its output.

Done criteria

  • [ ] scripts/shoot-topshelf-hero.sh produces a seamless ~2-min 4K cross-dissolve loop → public/topshelf/hero.mp4 + poster.png.
  • [ ] TopShelfHero host-tested; URLs point at the Codeberg Pages topshelf/ path.
  • [ ] PhysarumTopShelf tvOS extension builds + embeds in the tvOS app; macOS+iOS+tvOS builds green; host suite green.
  • [ ] dist/topshelf/ ships with the web build (Codeberg Pages will serve it after push).
  • [ ] Device sign-off step added to Handover-AppStore.md.

Notes / honest limits

  • The real Top Shelf experience is device-only (sim doesn't show it) and conditional on top-row placement — that's Johannes' sign-off (Task 4 / handover).
  • .mp4 is the first cut; if the device shows only the poster (no autoplay), switch the hosted asset + videoURL to HLS (ffmpeg -f hls) and verify the .m3u8 content-type on Codeberg Pages.
  • Task 3's XcodeGen extension embedding into a multi-platform app is the known-fiddly part — iterate to green; the build is the gate.