Zum Inhalt

apple/PhysarumApp/Sources/Capture/CaptureService.swift

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)"
        }
    }
}