Zum Inhalt

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/encodePNG in PhysarumEngine/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 internen finalTex rendert (into: finalTex). Kein Verhaltens-Drift für PresetGallery / bestehende Tests.
  • encodePNG(_ tex:to:) bleibt; bekommt das Capture-Ziel übergeben (es nimmt schon tex).
  • Keine Shader-Änderung. Die Composite-Pass schreibt statt in finalTex in das übergebene target mit cover = 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:) (.mov HEVC / .mp4 H.264) auf einer dedizierten seriellen Queue; AVAssetWriterInput(mediaType:.video, outputSettings:) mit expectsMediaDataInRealTime = false; AVAssetWriterInputPixelBufferAdaptor mit Pixel-Buffer-Attributen (kCVPixelFormatType_32BGRA, kCVPixelBufferMetalCompatibilityKey = true, IOSurfaceProperties = [:], Export-W/H).
  • GPU-Pfad: pro Frame einen Buffer aus adaptor.pixelBufferPool ziehen (Pool existiert erst nach startWriting()/startSession(.zero)), via CVMetalTextureCache als MTLTexture wrappen (.bgra8Unorm), mit renderOffscreen(into:) hineinrendern. Die CVMetalTexture bis GPU-Completion halten (commandBuffer.addCompletedHandler/waitUntilCompleted), dann adaptor.append(_, withPresentationTime: presentationTime(n, fps)).
  • Backpressure: requestMediaDataWhenReady(on:)-Pull-Loop, append nur solange isReadyForMoreMediaData; nie blind spinnen. append-Bool prüfen, bei false writer.status/error lesen.
  • 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 (kein CVPixelBufferCreate pro Frame); BGRA↔32BGRA gepaart; Video opak (Alpha = 1).
  • Color: AVVideoColorPropertiesKey taggen (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.up oder camera). Ö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-Button Aufnehmen / Clip rendern.
  • Fortschritt: während running ein HUD (Determinate-ProgressView + Abbrechen). Bei done Ergebnis-Aktionen: iOS In Fotos sichern (primär, falls noch nicht auto) + ShareLink; macOS Sichern… (.fileExporter) + Teilen. Bei failed eine 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, finish aufgerufen, cancel bricht 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 test grü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 MTLCommandQueue auf einem MTLDevice parallel 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: encodePNG liest via getBytes → Still-Target braucht .shared (CPU-Readback) oder CIImage(mtlTexture:)→CIContext→CGImage. Video-Target ist IOSurface-backed (Pool). Zwei Ziel-Typen über renderOffscreen(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 → exportDimensions rundet auf gerade.