Zum Inhalt

Capture ab Live-Zustand + Auto-Thumbnails — Design

Datum: 2026-06-12 Scope: Native App (apple/PhysarumApp + apple/PhysarumEngine). Web-App unberührt. Status: Approved (Brainstorm 2026-06-12)

Problem

Zwei verwandte Lücken, die beim Geräte-Test auffielen:

  • A — Capture startet immer bei Frame 0. Foto und Video rendern aus einem frischen Seed + 180 Frames Warmup. Ein Foto ist damit faktisch immer die Startkonfiguration; Videos beginnen langweilig mit dem Aufbau aus dem Nichts. Es fehlt die Option, das aktuell sichtbare Bild festzuhalten („echter Screenshot") bzw. ein Video ab dem laufenden Zustand zu rendern.
  • B — Custom-Presets haben kein Thumbnail. Built-in-Presets zeigen gebakte PNGs aus dem Bundle; Custom-Presets fallen in der PresetCard auf einen leeren Platzhalter zurück.

Beide teilen denselben Kern: den aktuellen Sim-Zustand erfassen und daraus rendern.

Entscheidungen (aus dem Brainstorm)

  1. A-Semantik: Exakter Live-Snapshot als Kern-Primitive (nicht „Warm-Start"). Ermöglicht durch die Einsicht, dass der Snapshot-Capture bei der Live-Feldgröße (512) bleiben kann — der Composite-Pass skaliert ohnehin auf jede Export-Auflösung hoch. Damit entfällt das Pheromon-Resample/Agent-Umskalieren (512→720), und 512 ist optisch sogar treuer (Presets sind auf 512 getuned).
  2. A-UI: Foto = immer Snapshot (kein Schalter). Video = Zweier-Schalter „ab aktuellem Bild" (Default) / „von Anfang". Der Frisch-Modus bleibt der bisherige, deterministische Pfad.
  3. B-Quelle: (a) Live-Snapshot beim Speichern als Primär (kein extra Render-Lauf — der Snapshot ist schon da, und das Thumbnail ist „das Bild, das du gespeichert hast"). (b) frisch vorsimulierter Warm-Start als automatischer Fallback, wenn kein Live-Zustand verfügbar ist (Import, Programmpfad) und als Backfill für bestehende Custom-Presets ohne Thumbnail.

Architektur

Kern-Primitive: SimState + Snapshot/Restore (CPU-Memcpy)

Der Live-Zustand wird einmalig in rohe Bytes kopiert. CPU-Memcpy statt GPU-Blit: robust, device-unabhängig, keine Annahme über geteilte MTLDevice/MTLCommandQueue, kein Command-Buffer-Koordinationsrisiko. Es ist ein One-Shot beim Capture-/Save-Tap — die ~10 MB memcpy sind unkritisch.

Engine-API (PhysarumEngine):

// SimEngine
public struct SimState: Sendable {
    public let pheromone: Data   // width*height*16 bytes (SIMD4<Float>/Texel)
    public let agents: Data      // agentCount*24 bytes
    public let agentCount: Int
    public let width: Int
    public let height: Int
}
public func snapshot() -> SimState          // liest pheroRead + agentRead (.shared)
public func restore(_ state: SimState) throws // memcpy zurück; wirft bei Dimensions-Mismatch
  • restore() schreibt in pheroA/pheroB (beide auf den Snapshot-Stand, damit der erste Sub-Step korrekt liest) und agentA; setzt die Read/Write-Flags konsistent. Überspringt seed() und den 180-Frame-Warmup.
  • restore() ist nur zulässig, wenn width/height/agentCount der Ziel-Engine exakt zum Snapshot passen → die Capture-/Thumbnail-Engine wird bei den Snapshot-Dimensionen konstruiert. Mismatch wirft SimStateError.dimensionMismatch.

Feedback-State (Renderer): Die HDR-Feedback-Pipeline hat eigene Ping-Pong-Texturen plus ein feedbackReadIsA-Flag. Ohne sie startet das temporale Echo blank.

public struct FeedbackState: Sendable { /* zwei Textur-Snapshots + readIsA: Bool */ }
public func snapshotFeedback() -> FeedbackState?   // nil, wenn Feedback ungenutzt
public func restoreFeedback(_ state: FeedbackState)

Der Heads-Buffer ist transient (wird je Sub-Step geleert) → nicht Teil des Snapshots.

Snapshot-Konsistenz (Frame-Boundary)

Der Snapshot wird auf der Render-Queue am Frame-Ende gezogen — nach Abschluss des laufenden Command-Buffers (Semaphore-Wait / waitUntilCompleted), bevor der nächste Step encoded wird. Nie mitten in einem Sub-Step, sonst liest die CPU halb-geschriebene .shared-Buffer. Praktisch: RenderCoordinator setzt ein snapshotRequest-Flag und erfüllt es im Frame-Completion-Pfad.

// RenderCoordinator
public func takeSnapshot(completion: @escaping (LiveSnapshot) -> Void)

LiveSnapshot (App-Ebene) bündelt SimState + FeedbackState? + FieldSize + den aktiven PresetParams/SimFormat-Kontext, den der Capture-/Thumbnail-Pfad braucht.

Feature A — Capture-Flow

CaptureService.run() bekommt optional (snapshot: LiveSnapshot, mode: CaptureStart):

enum CaptureStart { case fromScratch, fromLiveState }
  • .fromLiveState: Capture-Engine + Renderer bei Snapshot-Feldgröße (512) bauen → sim.restore(...) + renderer.restoreFeedback(...). Foto: sofort einen Frame offscreen rendern (kein Step). Video: ab dem Snapshot die Clip-Dauer offline weitersimulieren (step() → renderOffscreen() → appendFrame() wie heute). Composite skaliert auf die Export-Größe (bis 4K) hoch.
  • .fromScratch: unverändert — 720-Feld, frischer Seed + Warmup, deterministisch.

UI (CaptureSheet): - Foto: kein Schalter, immer .fromLiveState. - Video: Segmented Control „Ab aktuellem Bild" (Default, .fromLiveState) / „Von Anfang" (.fromScratch). - Beim Öffnen des Sheets bzw. beim Auslösen ruft die View RenderCoordinator.takeSnapshot.

Feature B — Auto-Thumbnail

Neuer ThumbnailRenderer (App-Ebene, nutzt Engine-Offscreen-Pfad):

enum ThumbnailSource { case liveSnapshot(LiveSnapshot), warmStart(PresetParams) }
func renderThumbnail(_ source: ThumbnailSource) async throws -> Data  // PNG
  • (a) liveSnapshot: Engine bei Snapshot-Größe → restore() → ein Frame, Composite auf 256² → PNG. Kein extra Sim-Lauf.
  • (b) warmStart: frische Engine bei 256² → ~10 s vorsimuliert (≈300 Steps @ 30 fps) → ein Frame → PNG. Deterministisch.

Verdrahtung: - Speichern: AppState/Save-Pfad reicht den aktuellen LiveSnapshot an PresetStore.save weiter; nach erfolgreichem JSON-Write startet ein off-main Task renderThumbnail(.liveSnapshot) → PNG nach …/CustomPresets/Thumbnails/{id}.png. Kein Live-Zustand → .warmStart. - Backfill: Beim Öffnen der Library werden Custom-Presets ohne Thumbnail-PNG einmalig per .warmStart nachgerendert — gedrosselt (seriell, eins nach dem anderen, off-main), kein UI-Block. - Reload: Wenn ein PNG fertig ist, feuert ein Reload-Trigger (z.B. published Revision-Counter in AppState/PresetStore), damit die PresetCard neu lädt.

PresetCatalog.thumbnailURL(for:) erweitern: erst Custom-Thumbnails-Ordner prüfen, dann Bundle. PresetCard bleibt unverändert — der leere Platzhalter wird automatisch ersetzt.

Komponenten-Übersicht

Unit Datei Rolle
SimState, snapshot(), restore() PhysarumEngine/…/SimEngine.swift Sim-Zustand erfassen/wiederherstellen
FeedbackState, snapshotFeedback(), restoreFeedback() PhysarumEngine/…/Renderer.swift Echo-State erfassen/wiederherstellen
LiveSnapshot, takeSnapshot() PhysarumApp/…/Player/RenderCoordinator.swift Frame-konsistentes Bündel ziehen
CaptureStart, run(snapshot:mode:) PhysarumApp/…/Capture/CaptureService.swift Snapshot- vs. Frisch-Capture
Video-Start-Schalter PhysarumApp/…/Capture/CaptureSheet.swift UI
ThumbnailRenderer PhysarumApp/…/Capture/ThumbnailRenderer.swift (neu) Thumbnail aus Snapshot/Warm-Start
Custom-Thumbnail-Lookup PhysarumApp/…/Model/PresetCatalog.swift URL-Auflösung Custom > Bundle
Save-Hook + Backfill + Reload PhysarumApp/…/Model/PresetStore.swift (+ AppState) Verdrahtung

Testing (Host-GPU, Muster wie RendererCaptureTests)

  • snapshot()→restore() Round-Trip: Pheromon- + Agent-Bytes identisch.
  • restore() mit falscher Dimension → wirft dimensionMismatch.
  • Feedback-Snapshot Round-Trip: readIsA-Flag + Texturinhalt erhalten.
  • Thumbnail (a): LiveSnapshot → nicht-leeres 256²-PNG (CGImage nicht uniform).
  • Thumbnail (b): frische Engine, 300 Steps → nicht-leeres PNG.
  • PresetCatalog: Custom-Thumbnail-URL hat Vorrang vor Bundle-URL.
  • CaptureMath bleibt unverändert (Regressions-Sicherung).

Bewusste Abweichungen (→ AGENTS.md nachziehen)

  • Snapshot-Capture ist nicht-deterministisch (hängt vom Live-Moment ab). Der Frisch-Modus (.fromScratch) bleibt der deterministische Pfad. Die bisherige „Capture ist deterministisch"- Zusage gilt damit nur noch für den Frisch-Modus.
  • Snapshot-Capture rendert bei 512 statt 720 — bewusst (Treue zum Live-Bild, kein Resample); 4K-Export bleibt via Composite-Upscale möglich.
  • Web-App unberührt — rein nativ.

Bewusst nicht in Scope (YAGNI)

  • Resample-basierter Snapshot in höherer Sim-Auflösung (720/1024) — nicht nötig, 512 ist treuer.
  • GPU-Blit-Transfer zwischen Engines — CPU-Memcpy reicht für One-Shot.
  • Manuelles „Thumbnail neu generieren"-UI — Backfill + Save-Hook decken den Bedarf; ergänzbar, falls später gewünscht.
  • Thumbnail für Video-Presets / animierte Thumbnails.