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
PresetCardauf einen leeren Platzhalter zurück.
Beide teilen denselben Kern: den aktuellen Sim-Zustand erfassen und daraus rendern.
Entscheidungen (aus dem Brainstorm)¶
- 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).
- A-UI: Foto = immer Snapshot (kein Schalter). Video = Zweier-Schalter „ab aktuellem Bild" (Default) / „von Anfang". Der Frisch-Modus bleibt der bisherige, deterministische Pfad.
- 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 inpheroA/pheroB(beide auf den Snapshot-Stand, damit der erste Sub-Step korrekt liest) undagentA; setzt die Read/Write-Flags konsistent. Überspringtseed()und den 180-Frame-Warmup.restore()ist nur zulässig, wennwidth/height/agentCountder Ziel-Engine exakt zum Snapshot passen → die Capture-/Thumbnail-Engine wird bei den Snapshot-Dimensionen konstruiert. Mismatch wirftSimStateError.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.
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):
.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 → wirftdimensionMismatch.- 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.CaptureMathbleibt 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.