Physarum Native — Capture / Export Design Spec¶
Status: Approved · 2026-06-04 · author: Johannes (brainstormed with Claude) Builds on: the native authoring app (
2026-06-03-authoring-editor-design.md), the adaptive sim format (2026-06-04-adaptive-sim-format-design.md), and the existing engine offscreen-render path (renderOffscreen/savePNG/encodePNGinPhysarumEngine/Renderer/Renderer.swift). Mirrors the web Capture-Slice shipped in v1.1.0 (PNG / video / share-URL) for the two destinations that translate to native: high-resolution still and video clip.
1. Kontext & Ziel¶
Die native App kann live rendern und editieren, aber nichts exportieren. Dieser Increment bringt
Capture/Export: ein hochauflösendes Standbild und einen Video-Clip, beides plattform-idiomatisch
gespeichert/geteilt (iOS → Fotos + Teilen, macOS → Datei + Teilen). Die Engine ist bereits
capture-fähig: renderOffscreen() rendert den vollen Post-FX-Stack inkl. Temporal-Feedback
blockierend, sim.step() ist deterministisch, der PresetGallery-Pfad (resetFeedback() → N×
step()+renderOffscreen()) ist die bewährte Offline-Render-Schleife.
Share-URL bleibt web-only (kein URL-Preset-Modell nativ); Audio bleibt aus dem Video (Web nimmt auch nur den visuellen Buffer auf, und native Audio-Reaktivität ist noch nicht portiert).
2. Festgezurrte Entscheidungen (Brainstorming 2026-06-04)¶
| Achse | Entscheidung |
|---|---|
| Video-Modell | Saubere Offline-Render-Clips. User wählt Dauer + Auflösung; die Engine rendert den aktuellen Preset deterministisch Frame für Frame in einen AVAssetWriter — keine Dropped Frames, oft schneller als Echtzeit. Clip startet sauber aus Seed-Warmup (gleicher Look, eigene Evolution). Kein Live-„record-what-I-see" in v1 (out-of-scope, möglicher Fast-Follow). |
| Foto-Modell | Hochauflösender Export. Auflösung wählbar (bis 4K); das Feld wird wie im Web hochskaliert, voller Post-FX. |
| Auflösungs-Fidelity | Post-FX wird auf Baseline-Feldauflösung gerendert, nur das finale Composite wird auf die Export-Größe hochskaliert (Viewport-Cover, exakt wie die Live-View auf den großen Screen skaliert). Verhindert den „dünner/blasser bei höherer Render-Auflösung"-Effekt ([[physarum-preset-resolution-tuning]]). |
| Entkopplung | CaptureService ist vollständig vom Live-RenderCoordinator entkoppelt — eigene SimEngine + eigener Capture-Renderer, eigene Metal-Command-Queue, eigene serielle Background-Queue. Live-View läuft währenddessen ungestört weiter. |
| Video-Codec | Default HEVC in .mov, SDR, Display-P3-getaggt; Kompatibilitäts-Option H.264 .mp4. Beide 8-bit SDR (das Composite ist bgra8Unorm/tonemapped). HDR/10-bit out-of-scope (bräuchte eine 10-bit-Composite-Pipeline). |
| Still-Format | PNG (verlustfrei), P3/sRGB-getaggt. HEIC-Option später. |
| fps | 60 fps fix (wie Web). |
| Dauer | Picker: 5 / 10 / 15 / 30 s (Default 10 s). |
| Auflösung | Lange Kante als Picker; Video 720p/1080p/1440p (1280/1920/2560, Default 1080p), Foto 1080/2048/4096 (Default 2048). Kurze Kante = round(long / aspect), auf gerade Pixel gerundet. Aspect = aktuelles Feldformat (SimFormat). |
| Speichern iOS | In Fotos (PHAssetCreationRequest, .addOnly, NSPhotoLibraryAddUsageDescription) als Primär + ShareLink (Teilen-Sheet). |
| Speichern macOS | Datei (.fileExporter / NSSavePanel) als Primär + Teilen (NSSharingServicePicker) + optional „Zu Fotos hinzufügen". |
| tvOS | Capture vorerst out-of-scope (kein Fotos-Schreibzugriff; später Datei/AirDrop). |
3. Engine-Erweiterung (PhysarumEngine, minimal)¶
Heute rendert renderOffscreen() in den engenen finalTex (auf Feldauflösung erzeugt). Capture
braucht ein beliebig dimensioniertes Ziel. Eine neue Methode, die die bestehende Viewport-
Cover-Logik (Pass 4) wiederverwendet:
/// Render one full-post-FX frame of the current sim state into `target`,
/// scaling the field into it with the existing cover viewport. Internal
/// scene/bloom/feedback textures stay at the renderer's field resolution
/// (so the tuned look is preserved); only the final composite is upscaled.
/// Blocks until the GPU completes (the caller owns target's lifetime).
func renderOffscreen(sim: SimEngine, preset: PresetParams, into target: MTLTexture) throws
- Der bestehende
renderOffscreen(sim:preset:)wird ein dünner Wrapper, der in den internenfinalTexrendert (into: finalTex). Kein Verhaltens-Drift für PresetGallery / bestehende Tests. encodePNG(_ tex:to:)bleibt; bekommt das Capture-Ziel übergeben (es nimmt schontex).- Keine Shader-Änderung. Die Composite-Pass schreibt statt in
finalTexin das übergebenetargetmitcover = true(Feld füllt das Ziel, randlos).
4. CaptureService — plattform-agnostischer Kern¶
Eine @MainActor-fassbare Fassade plus ein Background-Worker. Besitzt eigene Metal-Objekte,
nie die des Live-Coordinators.
struct CaptureConfig: Equatable {
enum Kind: Equatable { case still; case video(durationSeconds: Int) }
var kind: Kind
var longEdge: Int // px, target long edge
var aspect: Double // long/short, from current SimFormat
var fps: Int = 60 // video only
var codec: VideoCodec = .hevc // video only
}
enum VideoCodec { case hevc, h264 } // → AVVideoCodecType + container
enum CaptureResult { case still(URL); case video(URL) }
@Observable final class CaptureService {
enum State: Equatable { case idle; case running(progress: Double); case done(CaptureResult); case failed(String) }
private(set) var state: State = .idle
func capture(preset: PresetParams, field: FieldSize, config: CaptureConfig) // async, off-main
func cancel()
}
Ablauf (Background-Queue):
1. Eigene MTLDevice/commandQueue (oder geteiltes Device, separate Queue), Capture-Renderer
+ SimEngine auf field (= aktuelles Live-Feldformat, damit der Look matcht), aus preset geseedet.
2. resetFeedback(), dann Warmup (~200–400 Frames step()+renderOffscreen(into: warmTarget))
bis das Temporal-Feedback eingeschwungen ist.
3. Still: ein Frame in das Export-Target (exportDimensions) rendern → encodePNG/CGImage → URL.
Video: für durationSeconds * fps Frames die §5-Schleife.
4. Fortschritt nach main melden (state = .running(p)); cancel() setzt ein Flag, das die Schleife prüft.
Reine, testbare Funktionen (kein GPU/AVFoundation):
func exportDimensions(aspect: Double, longEdge: Int) -> (w: Int, h: Int) // even-rounded
func captureFilename(presetName: String, date: Date, ext: String) -> String // "physarum-<slug>-<YYYYMMDD-HHmmss>.<ext>", web-konform
func frameCount(durationSeconds: Int, fps: Int) -> Int
func presentationTime(frameIndex: Int, fps: Int) -> CMTime // rationaler PTS
5. Video-Pipeline (AVFoundation, offline/fixed-cadence)¶
Nach der AVFoundation-Recherche (Offline ist für generative Kunst klar besser):
AVAssetWriter(outputURL:fileType:)(.movHEVC /.mp4H.264) auf einer dedizierten seriellen Queue;AVAssetWriterInput(mediaType:.video, outputSettings:)mitexpectsMediaDataInRealTime = false;AVAssetWriterInputPixelBufferAdaptormit Pixel-Buffer-Attributen (kCVPixelFormatType_32BGRA,kCVPixelBufferMetalCompatibilityKey = true,IOSurfaceProperties = [:], Export-W/H).- GPU-Pfad: pro Frame einen Buffer aus
adaptor.pixelBufferPoolziehen (Pool existiert erst nachstartWriting()/startSession(.zero)), viaCVMetalTextureCachealsMTLTexturewrappen (.bgra8Unorm), mitrenderOffscreen(into:)hineinrendern. DieCVMetalTexturebis GPU-Completion halten (commandBuffer.addCompletedHandler/waitUntilCompleted), dannadaptor.append(_, withPresentationTime: presentationTime(n, fps)). - Backpressure:
requestMediaDataWhenReady(on:)-Pull-Loop, append nur solangeisReadyForMoreMediaData; nie blind spinnen.append-Bool prüfen, beifalsewriter.status/errorlesen. - Finalisieren:
markAsFinished()→finishWriting { }, auf den Completion-Handler warten (status == .completed) bevor die URL an Fotos/Teilen geht. - Hygiene: jede Schleifen-Iteration in
autoreleasepool { }; Pool besitzt die Buffer-Allokation (keinCVPixelBufferCreatepro Frame); BGRA↔32BGRA gepaart; Video opak (Alpha = 1). - Color:
AVVideoColorPropertiesKeytaggen (P3-D65 / 709-Transfer / 709-Matrix) damit Photos/QuickTime/Web nicht raten.
6. UI — Capture-Surface¶
- Capture-Button in der Topbar (neben dem Format-Menü; Icon z.B.
square.and.arrow.upodercamera). Öffnet ein Capture-Sheet (compact) bzw. Popover (regular) — adaptiv wie das Format-Menü, idiomatisch (presentationCompactAdaptation). - Sheet-Inhalt: Segmented
Foto · Video; Auflösungs-Picker; (Video) Dauer- + Codec-Picker; Aktions-ButtonAufnehmen/Clip rendern. - Fortschritt: während
runningein HUD (Determinate-ProgressView + Abbrechen). BeidoneErgebnis-Aktionen: iOSIn Fotos sichern(primär, falls noch nicht auto) +ShareLink; macOSSichern…(.fileExporter) + Teilen. Beifailedeine Fehlermeldung. - Vollbild: der Capture-Button wird ins bestehende Tap-Reveal-Control-Cluster (neben „Exit fullscreen") aufgenommen, damit Capture auch im Vollbild erreichbar ist.
- Dateiname aus
captureFilename(...).
7. Speichern & Berechtigungen (plattform-dünn)¶
| Plattform | Primär | Sekundär | Berechtigung / Plist |
|---|---|---|---|
| iOS | In Fotos (PHAssetCreationRequest.addResource(with:.video/.photo, fileURL/data:) in performChanges) |
ShareLink(item: url) |
PHPhotoLibrary.requestAuthorization(for: .addOnly); NSPhotoLibraryAddUsageDescription in project.yml-Info |
| macOS | .fileExporter / NSSavePanel |
NSSharingServicePicker + optional „Zu Fotos hinzufügen" |
keine (user-driven) |
Wo möglich SwiftUI-ShareLink / .fileExporter cross-platform nutzen (weniger #if os()).
project.yml ist Signing/Info-SSOT → Plist-Keys dort ergänzen, dann xcodegen generate.
8. Testing & Verifikation¶
- Unit (App-Test-Target, kein GPU):
exportDimensions: Aspect 1→quadratisch, 16:9-Aspect→gerade W/H, Rundung auf gerade.captureFilename: Slug (lowercase, Diakritika weg, non-alnum→-) + Timestamp, web-konform.frameCount/presentationTime: 10 s@60 = 600 Frames; PTS monoton, rational, kein Drift.CaptureService-Orchestrierung über Protokolle (FrameRenderer,VideoSink) gemockt: Warmup-Count, Frame-Count, PTS-Reihenfolge,finishaufgerufen,cancelbricht ab — ohne echtes Metal/AVFoundation.- Integration / visuell (Johannes, GPU-Pfad):
- Foto: Export sieht aus wie die Live-View (kein dünner/blasser), bei 1080/2048/4096; landet in Fotos (iOS) / als Datei (macOS); Aspect korrekt (square/portrait/landscape).
- Video: 10 s-Clip rendert, spielt sauber ab (keine Dropped Frames/Ruckler), Dauer stimmt, HEVC
- H.264 beide abspielbar, Farben korrekt getaggt, landet in Fotos/Datei, Teilen funktioniert, Abbrechen bricht sauber ab (kein truncated File).
- Live-View läuft während des Captures ungestört weiter.
- Gate: Engine
swift testgrün; App-Suite grün; macOS + iOS-Sim BUILD SUCCEEDED; auf iPhone deployed.
9. Scope (in) / Out-of-scope¶
Inc 3f (Still): Engine renderOffscreen(into:) · CaptureService-Kern + reine Funktionen ·
Still-PNG-Export (Auflösungs-Picker) · Capture-UI-Shell (Sheet/Popover + HUD) · Speichern/Teilen
(iOS Fotos + macOS Datei/Teilen) · Info.plist · Unit-Tests.
Inc 3g (Video): AVAssetWriter-Offline-Pipeline (Adaptor + CVMetalTextureCache) · Dauer/Codec ·
Fortschritts-HUD + Abbrechen · Video speichern/teilen · Orchestrierungs-Tests.
Out-of-scope (später): Live „record-what-I-see" · Audio im Video (Audio noch nicht portiert) · HDR/10-bit-Export (Composite ist 8-bit SDR) · HEIC-Stills · tvOS-Capture (kein Fotos-Schreibzugriff) · Share-URL (web-only) · Snapshot des exakten aktuellen Sim-Zustands (Clip startet aus Seed-Warmup) · animierte Übergänge.
10. Offene Risiken / im Plan zu klären¶
- Look-Fidelity (wichtigstes Risiko): Export muss wie die Live-View aussehen. Sicherung: Post-FX auf Baseline-Feld, nur Composite hochskaliert (§2/§3). Visuell gegen die Live-View prüfen ([[physarum-preset-resolution-tuning]] — höhere Render-Auflösung wirkt sonst dünner/blasser).
- Zwei Renderer/Sim auf einem Device: separate Command-Queue + eigene Feedback-Textures — der
Capture-Pfad darf die Live-Feedback-Ping-Pong-Textures nicht teilen. Bestätigen, dass zwei
MTLCommandQueueauf einemMTLDeviceparallel sauber laufen (tun sie). - CVMetalTexture-Lifetime: vor GPU-Completion freigegeben → korrupte Frames. Im Completion-Handler halten/freigeben; Pool recycelt (Backpressure throttelt natürlich).
- Still-Target-Storage:
encodePNGliest viagetBytes→ Still-Target braucht.shared(CPU-Readback) oderCIImage(mtlTexture:)→CIContext→CGImage. Video-Target ist IOSurface-backed (Pool). Zwei Ziel-Typen überrenderOffscreen(into:)abstrahieren. - Determinismus vs. aktuelles Bild: Clip/Foto starten aus Seed-Warmup, nicht dem exakten On-Screen-Frame (vom User akzeptiert). Snapshot der Live-Sim-Buffer als spätere Erweiterung notiert.
- Performance/Dauer: 30 s@60 = 1800 Frames; sollte auf Apple-Silicon schneller als Echtzeit sein, aber HUD + Abbrechen Pflicht; sehr lange Clips ggf. langsam — Default kurz halten (10 s).
- Threading: alle Writer-Calls auf einer seriellen Background-Queue; Fortschritt/Ergebnis auf
main. - Even dimensions: Encoder mögen ungerade Größen nicht →
exportDimensionsrundet auf gerade.