import Foundation
import Metal
import PhysarumEngine
enum CaptureOutput: Equatable {
case file(URL, isVideo: Bool)
case livePhoto(still: URL, video: URL)
var primaryURL: URL {
switch self {
case .file(let u, _): return u
case .livePhoto(_, let v): return v
}
}
var isVideo: Bool {
switch self {
case .file(_, let v): return v
case .livePhoto: return true
}
}
}
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
}
@MainActor
@Observable
final class CaptureService {
enum State: Equatable {
case idle
case running(progress: Double)
case done(CaptureOutput)
case failed(String)
}
private(set) var state: State = .idle
private let queue = DispatchQueue(label: "de.jkaindl.physarum.capture", qos: .userInitiated)
private var cancelled = false
private let warmupFrames = 180 // feedback warm-up at field resolution (cheap)
func cancel() { cancelled = true }
func reset() { state = .idle; cancelled = false }
/// Render the current preset to a still or clip. `viewWidth/Height` is the live
/// player size and `format` is the user's SimFormat — together they derive the same
/// field the live renderer is using, so the export matches what's on screen exactly.
/// Runs off-main; reports progress/result on main.
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)
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)
// Mirror the live composite: cover (fill/crop) shows a view-aspect crop of the
// field; fit shows the whole field at its own aspect (no letterbox in the export).
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
if self.cancelled { self.state = .idle }
else { self.state = .done(output) }
}
} catch {
let msg = "\(error)"
Task { @MainActor in self.state = self.cancelled ? .idle : .failed(msg) }
}
}
}
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 {
p.agentCount = snap.sim.agentCount // exact count so restore() dimensions match
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: field + echo 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)
}
}
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 {
let d = MTLTextureDescriptor.texture2DDescriptor(pixelFormat: .bgra8Unorm, width: exportW, height: exportH, mipmapped: false)
d.usage = [.renderTarget, .shaderRead]
d.storageMode = .shared
guard let target = device.makeTexture(descriptor: d) else { throw CaptureError.noTexture }
// snapshot still = exact frozen frame; only advance if not restoring from live state
if !skipStep { sim.step() }
try renderer.renderOffscreen(sim: sim, preset: preset, into: target, cover: cover)
guard let img = renderer.cgImage(target) else { throw CaptureError.encode }
let base = FileManager.default.temporaryDirectory
.appendingPathComponent(captureFilename(presetName: presetName, date: Date(), ext: photoFormat.fileExtension))
switch photoFormat {
case .png:
guard ImageWriter.writePNG(img, to: base) else { throw CaptureError.encode }
return base
case .heic:
guard let out = ImageWriter.writeStill(img, baseURL: base, assetID: nil) else { throw CaptureError.encode }
return out
case .livePhoto:
assertionFailure("livePhoto is routed to captureLivePhoto in run(); should not reach captureStill")
guard let out = ImageWriter.writeStill(img, baseURL: base, assetID: nil) else { throw CaptureError.encode }
return out
}
}
private func captureVideo(device: MTLDevice, renderer: Renderer, sim: SimEngine, preset: PresetParams,
presetName: String, exportW: Int, exportH: Int, cover: Bool,
seconds: Int, config: CaptureConfig) throws -> URL {
let frames = clipFrameCount(durationSeconds: seconds, fps: config.fps)
let url = FileManager.default.temporaryDirectory
.appendingPathComponent(captureFilename(presetName: presetName, date: Date(), ext: config.codec.fileExtension))
let recorder = VideoClipRecorder(
device: device, url: url, width: exportW, height: exportH, fps: config.fps, codec: config.codec,
renderInto: { tex in
sim.step()
try renderer.renderOffscreen(sim: sim, preset: preset, into: tex, cover: cover)
})
// runClip is async; bridge it onto this synchronous background worker.
let sema = DispatchSemaphore(value: 0)
var result: Result<URL, Error>!
Task {
do {
let out = try await runClip(
frameCount: frames, fps: config.fps, recorder: recorder,
renderFrame: { _ in }, // rendering happens inside recorder.appendFrame → renderInto
isCancelled: { self.cancelled },
progress: { p in Task { @MainActor in
if case .running = self.state { self.state = .running(progress: p) }
} })
result = .success(out)
} catch { result = .failure(error) }
sema.signal()
}
sema.wait()
switch result! {
case .success(let u): return u
case .failure(let e): throw e
}
}
// A Live Photo is a 2-second clip so it always advances frames regardless of snapshot source.
// `skipStep` is accepted for API symmetry with captureStill but is intentionally unused here.
private func captureLivePhoto(device: MTLDevice, renderer: Renderer, sim: SimEngine, preset: PresetParams,
presetName: String, exportW: Int, exportH: Int, cover: Bool,
skipStep: Bool = false) throws -> CaptureOutput {
let fps = 60, seconds = 2
let frames = clipFrameCount(durationSeconds: seconds, fps: fps)
let assetID = UUID().uuidString
let videoURL = FileManager.default.temporaryDirectory
.appendingPathComponent(captureFilename(presetName: presetName, date: Date(), ext: "mov"))
let recorder = VideoClipRecorder(
device: device, url: videoURL, width: exportW, height: exportH, fps: fps, codec: .hevc,
renderInto: { tex in
sim.step()
try renderer.renderOffscreen(sim: sim, preset: preset, into: tex, cover: cover)
},
livePhoto: VideoClipRecorder.LivePhoto(assetID: assetID, keyFrameIndex: frames / 2))
let sema = DispatchSemaphore(value: 0)
var result: Result<URL, Error>!
Task {
do {
let out = try await runClip(
frameCount: frames, fps: fps, recorder: recorder,
renderFrame: { _ in },
isCancelled: { self.cancelled },
progress: { p in Task { @MainActor in
if case .running = self.state { self.state = .running(progress: p) }
} })
result = .success(out)
} catch { result = .failure(error) }
sema.signal()
}
sema.wait()
let movURL = try result!.get()
guard let key = recorder.keyFrameImage else { throw CaptureError.encode }
let stillBase = FileManager.default.temporaryDirectory
.appendingPathComponent(captureFilename(presetName: presetName, date: Date(), ext: "heic"))
guard let stillURL = ImageWriter.writeStill(key, baseURL: stillBase, assetID: assetID) else { throw CaptureError.encode }
return .livePhoto(still: stillURL, video: movURL)
}
}
enum CaptureError: Error, CustomStringConvertible {
case noDevice, noTexture, encode, cancelled, writer(String)
var description: String {
switch self {
case .noDevice: return "No Metal device available."
case .noTexture: return "Could not allocate the capture texture."
case .encode: return "Image encoding failed."
case .cancelled: return "Capture cancelled."
case .writer(let m): return "Recording failed: \(m)"
}
}
}