Zum Inhalt

Capture ab Live-Zustand + Auto-Thumbnails — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Capture (Foto/Video) kann den aktuellen Live-Sim-Zustand snapshotten und davon rendern; neue Custom-Presets bekommen automatisch ein Thumbnail.

Architecture: Neue Engine-Primitive SimState (CPU-memcpy von Pheromon+Agenten) + FeedbackState (Blit über shared-Staging, da Feedback-Texturen .private sind). RenderCoordinator.takeSnapshot() zieht beides frame-konsistent (queue-drain). Ein LiveSnapshotProvider (@Observable) macht den aktiven Coordinator für CaptureSheet + Save-Flow erreichbar. CaptureService rendert wahlweise aus dem Snapshot (bei Live-Feldgröße, Composite-Upscale auf Export) oder frisch. ThumbnailRenderer erzeugt 256²-PNGs aus Snapshot oder Warm-Start; PresetCatalog bevorzugt Custom-Thumbnails vor Bundle.

Tech Stack: Swift, Metal/MetalKit, SwiftUI, XCTest. Engine-Tests via swift test (Host-GPU), App-Tests via xcodebuild test.

Spec: docs/superpowers/specs/2026-06-12-capture-from-live-state-design.md

Build/Test-Referenz: - Engine: cd apple/PhysarumEngine && swift test - App: cd apple/PhysarumApp && xcodegen generate && xcodebuild -scheme Physarum -destination 'platform=macOS' test - Tests skippen ohne GPU via MTLCreateSystemDefaultDevice() → XCTSkip (Muster: RendererCaptureTests).


File Structure

Engine (apple/PhysarumEngine/Sources/PhysarumEngine/): - Renderer/SimEngine.swift — Modify: SimState, snapshot(), restore(_:), SimStateError. - Renderer/Renderer.swift — Modify: FeedbackState, snapshotFeedback(), restoreFeedback(_:).

App (apple/PhysarumApp/Sources/): - Capture/CaptureService.swift — Modify: CaptureStart enum, capture(..., start:snapshot:), run(...) Snapshot-Pfad. - Player/LiveSnapshot.swift — Create: LiveSnapshot Wert-Typ + LiveSnapshotProvider. - Player/RenderCoordinator.swift — Modify: takeSnapshot() -> LiveSnapshot. - Player/MetalView.swift — Modify: Provider-Verdrahtung in sync. - Capture/CaptureSheet.swift — Modify: Video-Start-Schalter, Snapshot-Übergabe. - App/RootView.swift — Modify: Provider an MetalView + CaptureSheet reichen. - Capture/ThumbnailRenderer.swift — Create: Snapshot/Warm-Start → PNG. - Model/PresetStore.swift — Modify: thumbnailURL(_:), thumbnailsDir, presetsMissingThumbnail(). - Model/PresetCatalog.swift — Modify: thumbnailURL(for:store:) Custom-Vorrang. - Model/AppState.swift — Modify: Save-Hook (Thumbnail-Render), Backfill, thumbnailRevision.

Tests: - PhysarumEngine/Tests/PhysarumEngineTests/SimStateTests.swift — Create. - PhysarumEngine/Tests/PhysarumEngineTests/FeedbackStateTests.swift — Create. - PhysarumApp/Tests/CaptureStartTests.swift — Create. - PhysarumApp/Tests/ThumbnailRendererTests.swift — Create. - PhysarumApp/Tests/ThumbnailStorageTests.swift — Create.


Task 1: SimState Snapshot/Restore in SimEngine

Files: - Modify: apple/PhysarumEngine/Sources/PhysarumEngine/Renderer/SimEngine.swift - Test: apple/PhysarumEngine/Tests/PhysarumEngineTests/SimStateTests.swift (create)

  • [ ] Step 1: Write the failing test
import XCTest
import Metal
@testable import PhysarumEngine

final class SimStateTests: XCTestCase {
    private func makeSim(field: Int = 64) throws -> (SimEngine, MTLDevice)? {
        guard let device = MTLCreateSystemDefaultDevice() else { return nil }
        let lib = ShaderLibrary(device: device)
        let preset = try TestFixtures.minimalPreset().params
        let s = try SimEngine(device: device, library: lib, width: field, height: field, preset: preset)
        return (s, device)
    }

    func testSnapshotRestoreRoundTripRestoresPheromoneAndAgents() throws {
        guard let (s, device) = try makeSim() else { throw XCTSkip("No Metal device") }
        for _ in 0..<20 { s.step() }
        let snap = s.snapshot()
        XCTAssertEqual(snap.width, 64)
        XCTAssertEqual(snap.height, 64)
        XCTAssertGreaterThan(snap.agentCount, 0)
        XCTAssertEqual(snap.pheromone.count, 64 * 64 * 16)
        XCTAssertEqual(snap.agents.count, snap.agentCount * agentByteStride)

        // Restore into a fresh engine of identical dimensions; its pheromone must match.
        let lib = ShaderLibrary(device: device)
        let preset = try TestFixtures.minimalPreset().params
        let target = try SimEngine(device: device, library: lib, width: 64, height: 64, preset: preset)
        try target.restore(snap)
        let restored = target.snapshot()
        XCTAssertEqual(restored.pheromone, snap.pheromone)
        XCTAssertEqual(restored.agents, snap.agents)
    }

    func testRestoreWithMismatchedDimensionsThrows() throws {
        guard let (s, device) = try makeSim(field: 64) else { throw XCTSkip("No Metal device") }
        let snap = s.snapshot()
        let lib = ShaderLibrary(device: device)
        let preset = try TestFixtures.minimalPreset().params
        let smaller = try SimEngine(device: device, library: lib, width: 32, height: 32, preset: preset)
        XCTAssertThrowsError(try smaller.restore(snap))
    }
}
  • [ ] Step 2: Run test to verify it fails

Run: cd apple/PhysarumEngine && swift test --filter SimStateTests Expected: FAIL — value of type 'SimEngine' has no member 'snapshot'.

  • [ ] Step 3: Add SimState, snapshot(), restore(), SimStateError to SimEngine

In SimEngine.swift, add at file scope (before public final class SimEngine):

/// An immutable snapshot of a SimEngine's mutable state (pheromone field + agents).
/// Raw bytes so it can seed a separate engine instance of identical dimensions.
public struct SimState: Sendable {
    public let pheromone: Data   // width*height*16 bytes (SIMD4<Float>/texel)
    public let agents: Data      // agentCount*agentByteStride bytes
    public let agentCount: Int
    public let width: Int
    public let height: Int
}

public enum SimStateError: Error { case dimensionMismatch }

Add inside the class (e.g. after pheromoneStats()):

/// Capture the current sim state for restoring into another engine of identical
/// dimensions. Reads the live `.shared` read-buffers — call only when the GPU is
/// not mid-step (the live path drains the queue in RenderCoordinator.takeSnapshot).
public func snapshot() -> SimState {
    let phBytes = width * height * 16
    let agBytes = agentCount * agentByteStride
    return SimState(
        pheromone: Data(bytes: pheroRead.contents(), count: phBytes),
        agents: Data(bytes: agentRead.contents(), count: agBytes),
        agentCount: agentCount, width: width, height: height)
}

/// Restore a snapshot into this engine, skipping seed + warmup. Dimensions must match.
/// Writes into the A-buffers and resets the read flags so the first sub-step reads them.
public func restore(_ state: SimState) throws {
    guard state.width == width, state.height == height, state.agentCount == agentCount else {
        throw SimStateError.dimensionMismatch
    }
    pheroReadIsA = true
    agentReadIsA = true
    state.pheromone.withUnsafeBytes { memcpy(pheroA.contents(), $0.baseAddress!, $0.count) }
    state.agents.withUnsafeBytes { memcpy(agentA.contents(), $0.baseAddress!, $0.count) }
}
  • [ ] Step 4: Run test to verify it passes

Run: cd apple/PhysarumEngine && swift test --filter SimStateTests Expected: PASS (or XCTSkip if no GPU — acceptable; CI has none, dev host does).

  • [ ] Step 5: Commit
git add apple/PhysarumEngine/Sources/PhysarumEngine/Renderer/SimEngine.swift \
        apple/PhysarumEngine/Tests/PhysarumEngineTests/SimStateTests.swift
git commit -m "feat(engine): SimState snapshot/restore for live-state capture"

Task 2: FeedbackState Snapshot/Restore in Renderer

The feedback echo textures are .private, so CPU memcpy is impossible — copy via a .shared staging texture (Blit), read back, and on restore replace + Blit back.

Files: - Modify: apple/PhysarumEngine/Sources/PhysarumEngine/Renderer/Renderer.swift - Test: apple/PhysarumEngine/Tests/PhysarumEngineTests/FeedbackStateTests.swift (create)

  • [ ] Step 1: Write the failing test
import XCTest
import Metal
@testable import PhysarumEngine

final class FeedbackStateTests: XCTestCase {
    func testFeedbackSnapshotRestoreRoundTrip() throws {
        guard let device = MTLCreateSystemDefaultDevice() else { throw XCTSkip("No Metal device") }
        let lib = ShaderLibrary(device: device)
        let preset = try TestFixtures.minimalPreset().params
        let r = try Renderer(device: device, library: lib, width: 64, height: 64)
        let s = try SimEngine(device: device, library: lib, width: 64, height: 64, preset: preset)
        // Build up some echo (feedbackAmount in the minimal preset may be 0; force a few frames anyway).
        r.resetFeedback()
        for _ in 0..<10 { s.step(); try r.renderOffscreen(sim: s, preset: preset) }
        let snap = try XCTUnwrap(r.snapshotFeedback())
        XCTAssertEqual(snap.width, 64)
        XCTAssertEqual(snap.height, 64)
        XCTAssertEqual(snap.texData.count, 64 * 64 * 8)   // rgba16Float = 8 bytes/texel

        // Restore into a fresh renderer and snapshot again — bytes must match.
        let r2 = try Renderer(device: device, library: lib, width: 64, height: 64)
        r2.restoreFeedback(snap)
        let snap2 = try XCTUnwrap(r2.snapshotFeedback())
        XCTAssertEqual(snap2.texData, snap.texData)
    }

    func testRestoreFeedbackWrongSizeIsNoOp() throws {
        guard let device = MTLCreateSystemDefaultDevice() else { throw XCTSkip("No Metal device") }
        let lib = ShaderLibrary(device: device)
        let r = try Renderer(device: device, library: lib, width: 64, height: 64)
        let bogus = FeedbackState(texData: Data(count: 32 * 32 * 8), width: 32, height: 32)
        r.restoreFeedback(bogus)   // must not crash; silently ignored
    }
}
  • [ ] Step 2: Run test to verify it fails

Run: cd apple/PhysarumEngine && swift test --filter FeedbackStateTests Expected: FAIL — has no member 'snapshotFeedback'.

  • [ ] Step 3: Add FeedbackState + snapshot/restore to Renderer

In Renderer.swift, add at file scope (before the Renderer class):

/// Snapshot of the temporal-feedback (echo) accumulation texture.
public struct FeedbackState: Sendable {
    public let texData: Data   // rgba16Float, width*height*8 bytes
    public let width: Int
    public let height: Int
}

Add inside the class (the renderer already exposes device, queue, width, height, feedbackRead, feedbackTexA, feedbackReadIsA, feedbackWasActive):

private func makeSharedFeedbackStaging() -> MTLTexture? {
    let d = MTLTextureDescriptor.texture2DDescriptor(
        pixelFormat: .rgba16Float, width: width, height: height, mipmapped: false)
    d.storageMode = .shared
    d.usage = [.shaderRead]
    return device.makeTexture(descriptor: d)
}

/// Read the current echo accumulation back into CPU bytes (via a shared-staging Blit,
/// since the feedback textures are `.private`). Returns nil if allocation fails.
public func snapshotFeedback() -> FeedbackState? {
    guard let staging = makeSharedFeedbackStaging(),
          let cb = queue.makeCommandBuffer(), let blit = cb.makeBlitCommandEncoder() else { return nil }
    blit.copy(from: feedbackRead, sourceSlice: 0, sourceLevel: 0,
              sourceOrigin: MTLOrigin(x: 0, y: 0, z: 0),
              sourceSize: MTLSize(width: width, height: height, depth: 1),
              to: staging, destinationSlice: 0, destinationLevel: 0,
              destinationOrigin: MTLOrigin(x: 0, y: 0, z: 0))
    blit.endEncoding(); cb.commit(); cb.waitUntilCompleted()
    let bpr = width * 8
    var raw = [UInt8](repeating: 0, count: bpr * height)
    staging.getBytes(&raw, bytesPerRow: bpr, from: MTLRegionMake2D(0, 0, width, height), mipmapLevel: 0)
    return FeedbackState(texData: Data(raw), width: width, height: height)
}

/// Restore an echo snapshot into feedbackTexA and mark the echo as active so the next
/// frame does not re-clear it. Silently ignores a size mismatch.
public func restoreFeedback(_ state: FeedbackState) {
    guard state.width == width, state.height == height,
          let staging = makeSharedFeedbackStaging() else { return }
    let bpr = width * 8
    state.texData.withUnsafeBytes { ptr in
        staging.replace(region: MTLRegionMake2D(0, 0, width, height), mipmapLevel: 0,
                        withBytes: ptr.baseAddress!, bytesPerRow: bpr)
    }
    guard let cb = queue.makeCommandBuffer(), let blit = cb.makeBlitCommandEncoder() else { return }
    blit.copy(from: staging, sourceSlice: 0, sourceLevel: 0,
              sourceOrigin: MTLOrigin(x: 0, y: 0, z: 0),
              sourceSize: MTLSize(width: width, height: height, depth: 1),
              to: feedbackTexA, destinationSlice: 0, destinationLevel: 0,
              destinationOrigin: MTLOrigin(x: 0, y: 0, z: 0))
    blit.endEncoding(); cb.commit(); cb.waitUntilCompleted()
    feedbackReadIsA = true
    feedbackWasActive = true
}

Note: feedbackRead / feedbackTexA / feedbackReadIsA / feedbackWasActive are existing private members (SimEngine analog confirmed in Renderer.swift:35–141). If feedbackTexA's .usage lacks blit-destination, no change is needed — Metal blit targets any non-framebufferOnly texture.

  • [ ] Step 4: Run test to verify it passes

Run: cd apple/PhysarumEngine && swift test --filter FeedbackStateTests Expected: PASS (or XCTSkip without GPU).

  • [ ] Step 5: Commit
git add apple/PhysarumEngine/Sources/PhysarumEngine/Renderer/Renderer.swift \
        apple/PhysarumEngine/Tests/PhysarumEngineTests/FeedbackStateTests.swift
git commit -m "feat(engine): FeedbackState echo snapshot/restore via shared-staging blit"

Task 3: CaptureService Snapshot-Pfad

Render either from a LiveSnapshot (restore into a sim at snapshot dimensions, no seed/warmup) or fresh (today's behavior). For a still in snapshot mode: render immediately, no step().

Files: - Modify: apple/PhysarumApp/Sources/Capture/CaptureService.swift - Test: apple/PhysarumApp/Tests/CaptureStartTests.swift (create)

  • [ ] Step 1: Write the failing test (pure decision logic)

CaptureStart mapping is the testable unit; full GPU capture is covered by ThumbnailRenderer tests.

import XCTest
@testable import Physarum

final class CaptureStartTests: XCTestCase {
    func testStillAlwaysUsesSnapshotWhenAvailable() {
        XCTAssertEqual(resolveCaptureStart(isVideo: false, videoFromScratch: false, hasSnapshot: true), .fromLiveState)
        XCTAssertEqual(resolveCaptureStart(isVideo: false, videoFromScratch: true,  hasSnapshot: true), .fromLiveState)
    }
    func testVideoHonorsToggle() {
        XCTAssertEqual(resolveCaptureStart(isVideo: true, videoFromScratch: false, hasSnapshot: true), .fromLiveState)
        XCTAssertEqual(resolveCaptureStart(isVideo: true, videoFromScratch: true,  hasSnapshot: true), .fromScratch)
    }
    func testNoSnapshotAlwaysFromScratch() {
        XCTAssertEqual(resolveCaptureStart(isVideo: false, videoFromScratch: false, hasSnapshot: false), .fromScratch)
        XCTAssertEqual(resolveCaptureStart(isVideo: true,  videoFromScratch: false, hasSnapshot: false), .fromScratch)
    }
}
  • [ ] Step 2: Run test to verify it fails

Run: cd apple/PhysarumApp && xcodebuild -scheme Physarum -destination 'platform=macOS' test 2>&1 | grep -i CaptureStartTests Expected: build error — cannot find 'resolveCaptureStart'.

  • [ ] Step 3: Add CaptureStart, resolveCaptureStart, and the snapshot render path

In CaptureService.swift, add at file scope:

enum CaptureStart: Equatable { case fromScratch, fromLiveState }

/// Photo always snapshots the live moment; video honors the user's toggle.
/// Without a snapshot (e.g. nothing rendered yet) we always start fresh.
func resolveCaptureStart(isVideo: Bool, videoFromScratch: Bool, hasSnapshot: Bool) -> CaptureStart {
    guard hasSnapshot else { return .fromScratch }
    if !isVideo { return .fromLiveState }
    return videoFromScratch ? .fromScratch : .fromLiveState
}

Extend capture(...) with two new params (default keep old behavior):

func capture(preset: PresetParams, presetName: String,
             viewWidth: Double, viewHeight: Double, cover: Bool,
             format: SimFormat, config: CaptureConfig,
             start: CaptureStart = .fromScratch, snapshot: LiveSnapshot? = nil) {
    cancelled = false
    state = .running(progress: 0)
    // Snapshot mode renders at the live field size (no resample); fresh mode uses the export field.
    let field: FieldSize = (start == .fromLiveState ? snapshot?.field : nil)
        ?? targetFieldSize(viewWidth: viewWidth, viewHeight: viewHeight, format: format)
    let viewAspect = viewWidth / max(1, viewHeight)
    let fieldAspect = Double(field.width) / Double(field.height)
    let aspect = cover ? viewAspect : fieldAspect
    let (w, h) = exportDimensions(aspectWH: aspect, longEdge: config.longEdge)
    let snap = (start == .fromLiveState) ? snapshot : nil
    queue.async { [weak self] in
        guard let self else { return }
        do {
            let output = try self.run(preset: preset, presetName: presetName,
                                      field: field, exportW: w, exportH: h, cover: cover,
                                      config: config, snapshot: snap)
            Task { @MainActor in self.state = self.cancelled ? .idle : .done(output) }
        } catch {
            let msg = "\(error)"
            Task { @MainActor in self.state = self.cancelled ? .idle : .failed(msg) }
        }
    }
}

Modify run(...) to accept snapshot: LiveSnapshot? and branch the seed/warmup:

private func run(preset: PresetParams, presetName: String, field: FieldSize,
                 exportW: Int, exportH: Int, cover: Bool, config: CaptureConfig,
                 snapshot: LiveSnapshot? = nil) throws -> CaptureOutput {
    guard let device = MTLCreateSystemDefaultDevice() else { throw CaptureError.noDevice }
    let lib = ShaderLibrary(device: device)
    var p = preset
    let renderer = try Renderer(device: device, library: lib, width: field.width, height: field.height)

    let sim: SimEngine
    if let snap = snapshot {
        // Exact agentCount from the snapshot so restore() dimensions match.
        p.agentCount = snap.sim.agentCount
        sim = try SimEngine(device: device, library: lib, width: field.width, height: field.height, preset: p)
        try sim.restore(snap.sim)
        if let fb = snap.feedback { renderer.restoreFeedback(fb) } else { renderer.resetFeedback() }
        // No warmup: the echo + field are already mature.
    } else {
        p.agentCount = densityScaledAgentCount(preset.agentCount, fieldWidth: field.width, fieldHeight: field.height)
        sim = try SimEngine(device: device, library: lib, width: field.width, height: field.height, preset: p)
        renderer.resetFeedback()
        for _ in 0..<warmupFrames {
            if cancelled { throw CaptureError.cancelled }
            sim.step()
            try renderer.renderOffscreen(sim: sim, preset: p)
        }
    }

    switch config.kind {
    case .still:
        if config.photoFormat == .livePhoto {
            return try captureLivePhoto(device: device, renderer: renderer, sim: sim, preset: p,
                                        presetName: presetName, exportW: exportW, exportH: exportH,
                                        cover: cover, skipStep: snapshot != nil)
        } else {
            let url = try captureStill(device: device, renderer: renderer, sim: sim, preset: p,
                                       presetName: presetName, exportW: exportW, exportH: exportH, cover: cover,
                                       photoFormat: config.photoFormat, skipStep: snapshot != nil)
            return .file(url, isVideo: false)
        }
    case .video(let seconds):
        let url = try captureVideo(device: device, renderer: renderer, sim: sim, preset: p,
                                   presetName: presetName, exportW: exportW, exportH: exportH,
                                   cover: cover, seconds: seconds, config: config)
        return .file(url, isVideo: true)
    }
}

In captureStill(...) add skipStep: Bool and guard the step (snapshot still = exact frozen frame):

private func captureStill(device: MTLDevice, renderer: Renderer, sim: SimEngine, preset: PresetParams,
                          presetName: String, exportW: Int, exportH: Int, cover: Bool,
                          photoFormat: PhotoFormat, skipStep: Bool = false) throws -> URL {
    // ... unchanged texture setup ...
    if !skipStep { sim.step() }
    try renderer.renderOffscreen(sim: sim, preset: preset, into: target, cover: cover)
    // ... unchanged write path ...
}

For captureLivePhoto(...), add skipStep: Bool = false to the signature for symmetry; its recorder loop calls sim.step() per frame (a Live Photo is a clip, so it always advances) — the flag is currently unused there but keeps the call sites uniform. Document with a one-line comment.

  • [ ] Step 4: Run test to verify it passes

Run: cd apple/PhysarumApp && xcodebuild -scheme Physarum -destination 'platform=macOS' test 2>&1 | grep -iE "CaptureStartTests|TEST SUCCEEDED|FAILED" Expected: PASS.

  • [ ] Step 5: Commit
git add apple/PhysarumApp/Sources/Capture/CaptureService.swift apple/PhysarumApp/Tests/CaptureStartTests.swift
git commit -m "feat(capture): render from a live snapshot (still=frozen frame, video=continue)"

NOTE: LiveSnapshot is defined in Task 4. If implementing strictly in order, stub struct LiveSnapshot { let sim: SimState; let feedback: FeedbackState?; let field: FieldSize } minimally here, then replace with the full type in Task 4. Subagent-driven execution may reorder so Task 4 lands first — both orders compile.


Task 4: LiveSnapshot + RenderCoordinator.takeSnapshot() + LiveSnapshotProvider

Files: - Create: apple/PhysarumApp/Sources/Player/LiveSnapshot.swift - Modify: apple/PhysarumApp/Sources/Player/RenderCoordinator.swift - Modify: apple/PhysarumApp/Sources/Player/MetalView.swift

  • [ ] Step 1: Create LiveSnapshot.swift
import PhysarumEngine

/// A frame-consistent capture of the live player's sim + echo state, plus the
/// render context needed to reproduce the on-screen look at any export size.
struct LiveSnapshot {
    let sim: SimState
    let feedback: FeedbackState?
    let field: FieldSize
    let preset: PresetParams
    let format: SimFormat
    let cover: Bool
}

/// Bridges the SwiftUI-owned RenderCoordinator (created in MetalView.makeCoordinator)
/// to views that need a snapshot (CaptureSheet, the save flow). MetalView.sync sets
/// `coordinator`; callers use `takeSnapshot()`. Weak so it never retains the coordinator.
@Observable
final class LiveSnapshotProvider {
    weak var coordinator: RenderCoordinator?
    func takeSnapshot() -> LiveSnapshot? { coordinator?.takeSnapshot() }
}
  • [ ] Step 2: Add takeSnapshot() to RenderCoordinator

In RenderCoordinator.swift, add:

/// Drain the shared queue so all in-flight frames have completed (the `.shared`
/// sim buffers are then stable for CPU readback), then snapshot sim + echo.
/// Called on main at a user tap — a brief one-frame stall is acceptable.
func takeSnapshot() -> LiveSnapshot {
    let drain = queue.makeCommandBuffer()
    drain?.commit()
    drain?.waitUntilCompleted()
    return LiveSnapshot(sim: sim.snapshot(),
                        feedback: renderer.snapshotFeedback(),
                        field: currentField, preset: params, format: format, cover: cover)
}
  • [ ] Step 3: Wire the provider in MetalView

In MetalView.swift, add a property and set it in sync:

var snapshotProvider: LiveSnapshotProvider? = nil

In sync(_:container:), after guard let c = coordinator else { return }:

    snapshotProvider?.coordinator = c
  • [ ] Step 4: Build to verify it compiles

Run: cd apple/PhysarumApp && xcodegen generate && xcodebuild -scheme Physarum -destination 'platform=macOS' build 2>&1 | tail -3 Expected: BUILD SUCCEEDED.

  • [ ] Step 5: Commit
git add apple/PhysarumApp/Sources/Player/LiveSnapshot.swift \
        apple/PhysarumApp/Sources/Player/RenderCoordinator.swift \
        apple/PhysarumApp/Sources/Player/MetalView.swift
git commit -m "feat(player): LiveSnapshot + takeSnapshot + provider wiring"

Task 5: CaptureSheet UI — Video-Schalter + Snapshot-Übergabe

Files: - Modify: apple/PhysarumApp/Sources/Capture/CaptureSheet.swift - Modify: apple/PhysarumApp/Sources/App/RootView.swift

  • [ ] Step 1: Pass the provider into CaptureSheet

In CaptureSheet.swift, add a stored property + state:

    let snapshotProvider: LiveSnapshotProvider
    @State private var videoFromScratch = false
    @State private var liveSnapshot: LiveSnapshot?

Take the snapshot when the sheet appears (freezes "the moment you opened capture"). Add to the root VStack of body:

        .onAppear { liveSnapshot = snapshotProvider.takeSnapshot() }
  • [ ] Step 2: Add the video start toggle (only when isVideo)

In the video-options section (near pickerRow("Resolution", $videoLong, ...)), add:

            Picker("Start", selection: $videoFromScratch) {
                Text("From current").tag(false)
                Text("From start").tag(true)
            }
            .pickerStyle(.segmented)
  • [ ] Step 3: Pass start + snapshot into service.capture in start()
    private func start() {
        saveState = .idle
        thumb = nil
        let resolved = resolveCaptureStart(isVideo: isVideo, videoFromScratch: videoFromScratch,
                                           hasSnapshot: liveSnapshot != nil)
        service.capture(preset: preset, presetName: presetName,
                        viewWidth: viewWidth, viewHeight: viewHeight, cover: cover,
                        format: format, config: config,
                        start: resolved, snapshot: liveSnapshot)
    }
  • [ ] Step 4: Provide the provider at the RootView call site

In RootView.swift, the view owns a LiveSnapshotProvider (add @State private var snapshotProvider = LiveSnapshotProvider()), passes it to MetalView(... snapshotProvider: snapshotProvider), and into captureSheet:

        CaptureSheet(preset: state.edited, presetName: state.selectedPreset.name,
                     viewWidth: Double(width), viewHeight: Double(height),
                     cover: cover, format: format, isPresented: isPresented,
                     snapshotProvider: snapshotProvider)

Find the existing MetalView(...) construction in RootView and add snapshotProvider: snapshotProvider to its arguments.

  • [ ] Step 5: Build + manual-verify checklist; commit

Run: cd apple/PhysarumApp && xcodegen generate && xcodebuild -scheme Physarum -destination 'platform=macOS' build 2>&1 | tail -3 Expected: BUILD SUCCEEDED.

git add apple/PhysarumApp/Sources/Capture/CaptureSheet.swift apple/PhysarumApp/Sources/App/RootView.swift
git commit -m "feat(capture): photo snapshots the live moment; video start toggle"

Manual device check (Johannes): a photo capture matches the on-screen image; a "from current" video starts where the player is; "from start" still builds from black.


Task 6: ThumbnailRenderer

Files: - Create: apple/PhysarumApp/Sources/Capture/ThumbnailRenderer.swift - Test: apple/PhysarumApp/Tests/ThumbnailRendererTests.swift (create)

  • [ ] Step 1: Write the failing test
import XCTest
import Metal
@testable import Physarum
import PhysarumEngine

final class ThumbnailRendererTests: XCTestCase {
    func testWarmStartProducesNonBlankPNG() throws {
        guard MTLCreateSystemDefaultDevice() != nil else { throw XCTSkip("No Metal device") }
        let preset = PresetCatalog.fallbackParams
        let data = try ThumbnailRenderer.render(.warmStart(preset))
        XCTAssertGreaterThan(data.count, 1000, "256² warm-start PNG should be non-trivial")
    }

    func testLiveSnapshotProducesNonBlankPNG() throws {
        guard let device = MTLCreateSystemDefaultDevice() else { throw XCTSkip("No Metal device") }
        let lib = ShaderLibrary(device: device)
        let preset = PresetCatalog.fallbackParams
        let sim = try SimEngine(device: device, library: lib, width: 64, height: 64, preset: preset)
        for _ in 0..<20 { sim.step() }
        let snap = LiveSnapshot(sim: sim.snapshot(), feedback: nil,
                                field: FieldSize(width: 64, height: 64),
                                preset: preset, format: .square, cover: false)
        let data = try ThumbnailRenderer.render(.liveSnapshot(snap))
        XCTAssertGreaterThan(data.count, 1000)
    }
}
  • [ ] Step 2: Run test to verify it fails

Run: cd apple/PhysarumApp && xcodebuild -scheme Physarum -destination 'platform=macOS' test 2>&1 | grep -i ThumbnailRendererTests Expected: build error — cannot find 'ThumbnailRenderer'.

  • [ ] Step 3: Create ThumbnailRenderer.swift
import Foundation
import Metal
import PhysarumEngine

enum ThumbnailSource {
    case liveSnapshot(LiveSnapshot)
    case warmStart(PresetParams)
}

/// Renders a 256² PNG for a preset, reusing the engine's offscreen path. Synchronous +
/// blocking — call off-main. liveSnapshot restores the captured state (one frame, no step);
/// warmStart simulates ~10 s fresh, then snaps a frame.
enum ThumbnailRenderer {
    static let side = 256
    static let warmupFrames = 300   // ~10 s @ 30 fps

    static func render(_ source: ThumbnailSource) throws -> Data {
        guard let device = MTLCreateSystemDefaultDevice() else { throw CaptureError.noDevice }
        let lib = ShaderLibrary(device: device)

        let renderer: Renderer
        let sim: SimEngine
        var preset: PresetParams

        switch source {
        case .liveSnapshot(let snap):
            let f = snap.field
            preset = snap.preset
            preset.agentCount = snap.sim.agentCount
            renderer = try Renderer(device: device, library: lib, width: f.width, height: f.height)
            sim = try SimEngine(device: device, library: lib, width: f.width, height: f.height, preset: preset)
            try sim.restore(snap.sim)
            if let fb = snap.feedback { renderer.restoreFeedback(fb) } else { renderer.resetFeedback() }
        case .warmStart(let p):
            // A modest field keeps the warm-start fast; 256 matches the export side.
            preset = p
            preset.agentCount = densityScaledAgentCount(p.agentCount, fieldWidth: side, fieldHeight: side)
            renderer = try Renderer(device: device, library: lib, width: side, height: side)
            sim = try SimEngine(device: device, library: lib, width: side, height: side, preset: preset)
            renderer.resetFeedback()
            for _ in 0..<warmupFrames { sim.step(); try renderer.renderOffscreen(sim: sim, preset: preset) }
        }

        let d = MTLTextureDescriptor.texture2DDescriptor(pixelFormat: .bgra8Unorm, width: side, height: side, mipmapped: false)
        d.usage = [.renderTarget, .shaderRead]
        d.storageMode = .shared
        guard let target = device.makeTexture(descriptor: d) else { throw CaptureError.noTexture }
        try renderer.renderOffscreen(sim: sim, preset: preset, into: target, cover: true)
        guard let img = renderer.cgImage(target) else { throw CaptureError.encode }
        let url = FileManager.default.temporaryDirectory.appendingPathComponent("thumb-\(UUID().uuidString).png")
        guard ImageWriter.writePNG(img, to: url) else { throw CaptureError.encode }
        defer { try? FileManager.default.removeItem(at: url) }
        return try Data(contentsOf: url)
    }
}
  • [ ] Step 4: Run test to verify it passes

Run: cd apple/PhysarumApp && xcodebuild -scheme Physarum -destination 'platform=macOS' test 2>&1 | grep -iE "ThumbnailRendererTests|SUCCEEDED|FAILED" Expected: PASS.

  • [ ] Step 5: Commit
git add apple/PhysarumApp/Sources/Capture/ThumbnailRenderer.swift apple/PhysarumApp/Tests/ThumbnailRendererTests.swift
git commit -m "feat(thumbnail): ThumbnailRenderer (live snapshot + warm-start) → 256px PNG"

Task 7: Custom-Thumbnail-Storage + PresetCatalog-Vorrang

Files: - Modify: apple/PhysarumApp/Sources/Model/PresetStore.swift - Modify: apple/PhysarumApp/Sources/Model/PresetCatalog.swift - Test: apple/PhysarumApp/Tests/ThumbnailStorageTests.swift (create)

  • [ ] Step 1: Write the failing test
import XCTest
@testable import Physarum

final class ThumbnailStorageTests: XCTestCase {
    private func tmpStore() -> PresetStore {
        let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
        return PresetStore(baseDir: dir)
    }

    func testWriteAndResolveCustomThumbnail() throws {
        let store = tmpStore()
        let png = Data([0x89, 0x50, 0x4E, 0x47])   // not a real PNG; URL existence is what we test
        try store.writeThumbnail(png, for: "custom-x")
        let url = try XCTUnwrap(store.thumbnailURL(for: "custom-x"))
        XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
    }

    func testCatalogPrefersCustomOverBundle() throws {
        let store = tmpStore()
        try store.writeThumbnail(Data([1, 2, 3]), for: "custom-y")
        let url = PresetCatalog.thumbnailURL(for: "custom-y", store: store)
        XCTAssertEqual(url?.lastPathComponent, "custom-y.png")
    }

    func testPresetsMissingThumbnail() throws {
        let store = tmpStore()
        try store.writeThumbnail(Data([1]), for: "has")
        let missing = store.presetsMissingThumbnail(ids: ["has", "nope"])
        XCTAssertEqual(missing, ["nope"])
    }
}
  • [ ] Step 2: Run test to verify it fails

Run: cd apple/PhysarumApp && xcodebuild -scheme Physarum -destination 'platform=macOS' test 2>&1 | grep -i ThumbnailStorageTests Expected: build error — has no member 'writeThumbnail'.

  • [ ] Step 3: Add thumbnail storage to PresetStore + catalog override

In PresetStore.swift, add:

    private var thumbnailsDir: URL { dir.appendingPathComponent("Thumbnails", isDirectory: true) }

    /// Write a thumbnail PNG for a custom preset id (creates the Thumbnails dir on first use).
    func writeThumbnail(_ png: Data, for id: String) throws {
        try fm.createDirectory(at: thumbnailsDir, withIntermediateDirectories: true)
        try png.write(to: thumbnailsDir.appendingPathComponent("\(id).png"))
    }

    /// URL of a custom preset's thumbnail PNG, if present.
    func thumbnailURL(for id: String) -> URL? {
        let u = thumbnailsDir.appendingPathComponent("\(id).png")
        return fm.fileExists(atPath: u.path) ? u : nil
    }

    /// Of the given ids, those without a thumbnail PNG (backfill candidates).
    func presetsMissingThumbnail(ids: [String]) -> [String] {
        ids.filter { thumbnailURL(for: $0) == nil }
    }

    /// Also delete a custom preset's thumbnail (extend the existing `delete`).
    func deleteThumbnail(for id: String) {
        try? fm.removeItem(at: thumbnailsDir.appendingPathComponent("\(id).png"))
    }

Extend the existing delete(_:) to also drop the thumbnail:

    func delete(_ id: String) {
        try? fm.removeItem(at: fileURL(id))
        deleteThumbnail(for: id)
        writeOrder(loadOrder().filter { $0 != id })
    }

In PresetCatalog.swift, add a store-aware overload (custom wins over bundle):

    /// Thumbnail URL preferring a custom (user-saved) PNG over a bundled one.
    static func thumbnailURL(for id: String, store: PresetStore, bundle: Bundle = .main) -> URL? {
        store.thumbnailURL(for: id) ?? thumbnailURL(for: id, bundle: bundle)
    }
  • [ ] Step 4: Run test to verify it passes

Run: cd apple/PhysarumApp && xcodebuild -scheme Physarum -destination 'platform=macOS' test 2>&1 | grep -iE "ThumbnailStorageTests|SUCCEEDED|FAILED" Expected: PASS.

  • [ ] Step 5: Update PresetCard call site to pass the store

Find the PresetCatalog.thumbnailURL(for: preset.id) call in Components.swift and change it to the store-aware overload. PresetCard needs the store passed down (or the resolved URL passed in). Simplest: resolve the URL where PresetCard is constructed (the Library view already has the store/AppState) and pass thumbURL: URL? into PresetCard. Keep PresetCard itself store-agnostic.

cd apple/PhysarumApp && xcodebuild -scheme Physarum -destination 'platform=macOS' build 2>&1 | tail -3
  • [ ] Step 6: Commit
git add apple/PhysarumApp/Sources/Model/PresetStore.swift apple/PhysarumApp/Sources/Model/PresetCatalog.swift \
        apple/PhysarumApp/Sources/**/Components.swift apple/PhysarumApp/Tests/ThumbnailStorageTests.swift
git commit -m "feat(thumbnail): custom thumbnail storage + catalog prefers custom over bundle"

Task 8: Save-Hook — Thumbnail beim Speichern eines Custom-Presets

Files: - Modify: apple/PhysarumApp/Sources/Model/AppState.swift

  • [ ] Step 1: Add a thumbnail-generation hook + reload counter

In AppState.swift, add a published revision the UI observes to reload thumbnails, plus a provider reference (set by RootView), and a helper:

    /// Bumped when a thumbnail PNG is (re)written, so PresetCards reload.
    var thumbnailRevision = 0
    /// Set by RootView so the save flow can snapshot the live image.
    weak var snapshotProvider: LiveSnapshotProvider?

    /// Render + persist a thumbnail for a custom preset, off-main. Live snapshot if available,
    /// else a fresh warm-start. Bumps `thumbnailRevision` on completion.
    func generateThumbnail(for id: String, params: PresetParams) {
        let source: ThumbnailSource = (snapshotProvider?.takeSnapshot()).map { .liveSnapshot($0) }
            ?? .warmStart(params)
        let store = self.store
        Task.detached(priority: .utility) {
            guard let png = try? ThumbnailRenderer.render(source) else { return }
            try? store.writeThumbnail(png, for: id)
            await MainActor.run { self.thumbnailRevision += 1 }
        }
    }

takeSnapshot() must run on main (it drains the render queue) — generateThumbnail is called from the main-actor save methods, so the snapshot is taken before the detached task. Good.

  • [ ] Step 2: Call it from saveAsNew and saveOverwrite

At the end of saveAsNew(...) (after baseline = edited):

        generateThumbnail(for: id, params: edited)

At the end of saveOverwrite() (after baseline = edited), using the selected id:

        generateThumbnail(for: selectedID, params: edited)
  • [ ] Step 3: Wire the provider in RootView

Where RootView creates AppState/holds snapshotProvider, set state.snapshotProvider = snapshotProvider (e.g. in .onAppear of the root view, or right after the @State initializer is available).

  • [ ] Step 4: Build to verify it compiles

Run: cd apple/PhysarumApp && xcodegen generate && xcodebuild -scheme Physarum -destination 'platform=macOS' build 2>&1 | tail -3 Expected: BUILD SUCCEEDED.

  • [ ] Step 5: Commit
git add apple/PhysarumApp/Sources/Model/AppState.swift apple/PhysarumApp/Sources/App/RootView.swift
git commit -m "feat(thumbnail): auto-generate thumbnail on custom preset save (live snapshot / warm-start)"

Task 9: Backfill bestehender thumbnailloser Custom-Presets

Files: - Modify: apple/PhysarumApp/Sources/Model/AppState.swift

  • [ ] Step 1: Write the failing test (which ids need backfill)

Add to ThumbnailStorageTests.swift:

    func testBackfillTargetsOnlyMissing() throws {
        let store = tmpStore()
        try store.writeThumbnail(Data([1]), for: "a")
        XCTAssertEqual(store.presetsMissingThumbnail(ids: ["a", "b", "c"]), ["b", "c"])
    }

(Re-uses the presetsMissingThumbnail from Task 7 — this asserts the backfill selection logic.)

  • [ ] Step 2: Run test to verify it passes already (logic exists)

Run: cd apple/PhysarumApp && xcodebuild -scheme Physarum -destination 'platform=macOS' test 2>&1 | grep -i testBackfillTargetsOnlyMissing Expected: PASS (the selection helper landed in Task 7; this locks the backfill contract).

  • [ ] Step 3: Add a throttled backfill driver to AppState
    /// Generate warm-start thumbnails for custom presets that lack one, one at a time
    /// (off-main, serialized) so opening the library never blocks and never floods the GPU.
    func backfillThumbnails() {
        let missing = store.presetsMissingThumbnail(ids: customs.map { $0.id })
        guard !missing.isEmpty else { return }
        let byId = Dictionary(uniqueKeysWithValues: customs.map { ($0.id, $0.params) })
        let store = self.store
        Task.detached(priority: .background) {
            for id in missing {
                guard let params = byId[id] else { continue }
                guard let png = try? ThumbnailRenderer.render(.warmStart(params)) else { continue }
                try? store.writeThumbnail(png, for: id)
                await MainActor.run { self.thumbnailRevision += 1 }
            }
        }
    }
  • [ ] Step 4: Trigger it when the library opens

In RootView.swift, where the library sheet is presented (the showLibrary binding / its .onChange or the library view's .onAppear), call state.backfillThumbnails() once when it becomes visible.

  • [ ] Step 5: Build, run full app test suite, commit
cd apple/PhysarumApp && xcodegen generate && xcodebuild -scheme Physarum -destination 'platform=macOS' test 2>&1 | tail -5
git add apple/PhysarumApp/Sources/Model/AppState.swift apple/PhysarumApp/Sources/App/RootView.swift \
        apple/PhysarumApp/Tests/ThumbnailStorageTests.swift
git commit -m "feat(thumbnail): throttled backfill of thumbnailless custom presets on library open"

Task 10: Doc-Sync (AGENTS.md + Engine smoke gate)

Files: - Modify: AGENTS.md

  • [ ] Step 1: Run the full gates once
cd apple/PhysarumEngine && swift test 2>&1 | tail -3
cd apple/PhysarumEngine && swift run EngineCheck 2>&1 | tail -3   # Xcode-free smoke gate
cd apple/PhysarumApp && xcodegen generate && xcodebuild -scheme Physarum -destination 'platform=macOS' test 2>&1 | tail -3

Expected: Engine + App suites green; EngineCheck ✓.

  • [ ] Step 2: Add an "Inc 9" status block to AGENTS.md

Document: the new SimState/FeedbackState snapshot primitives; Capture from live state (photo always snapshots, video toggle); auto-thumbnails (live-snapshot primary, warm-start fallback + backfill); and the deliberate deviations — snapshot capture is non-deterministic (the fresh-mode .fromScratch path stays deterministic) and renders at 512 not 720. Note the updated final test counts.

  • [ ] Step 3: Commit
git add AGENTS.md
git commit -m "docs(agents): Inc 9 — capture from live state + auto-thumbnails"

Self-Review (durchgeführt)

Spec coverage: A-Snapshot (Task 1–5), Feedback-Echo mitgenommen (Task 2), Foto=immer Snapshot / Video=Schalter (Task 3+5), 512-Feld + Composite-Upscale (Task 3 field aus Snapshot), B-Live-Snapshot (Task 6+8), Warm-Start-Fallback (Task 6+8), Backfill (Task 9), Custom>Bundle-Thumbnail (Task 7), Determinismus-/512-Abweichungen dokumentiert (Task 10). Alle Spec-Abschnitte abgedeckt.

Placeholder scan: Keine TBD/TODO; jeder Code-Step zeigt echten Code. UI-Verdrahtung in RootView (Task 5/8/9) verweist auf konkrete vorhandene Bindings (showLibrary, MetalView(...), captureSheet) statt erfundener Namen — der Ausführende lokalisiert die exakte Zeile.

Type consistency: LiveSnapshot (sim/feedback/field/preset/format/cover) konsistent in Task 3/4/6/8. SimState (pheromone/agents/agentCount/width/height) in Task 1/3/6. FeedbackState (texData/width/height) in Task 2/6. CaptureStart/resolveCaptureStart in Task 3/5. ThumbnailSource/ThumbnailRenderer.render in Task 6/8/9. thumbnailRevision/snapshotProvider in Task 8/9. Konsistent.