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(),SimStateErrorto 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:
LiveSnapshotis defined in Task 4. If implementing strictly in order, stubstruct 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:
In sync(_:container:), after guard let c = coordinator else { return }:
- [ ] 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:
- [ ] 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.captureinstart()
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
PresetCardcall 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) —generateThumbnailis called from the main-actor save methods, so the snapshot is taken before the detached task. Good.
- [ ] Step 2: Call it from
saveAsNewandsaveOverwrite
At the end of saveAsNew(...) (after baseline = edited):
At the end of saveOverwrite() (after baseline = edited), using the selected id:
- [ ] 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
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.