SPB Git

spb/focale Public

Swift 100%
6.4 KB · 194 lines swift
Raw Blame History
1//2//  CameraModel.swift3//  Focale4//5//  Author: Simon-Pierre Boucher6//  Contact: contact@spboucher.ai7//8//  Main-actor model behind the capture screen. The shutter path here does9//  the minimum: snapshot context, enqueue the capture, return. Everything10//  else (saving, indexing) happens in detached work afterwards.11//1213import AVFoundation14import Foundation15import Observation1617@MainActor18@Observable19final class CameraModel {2021    let service = CaptureService()22    let controls = ManualControls()23    private let writer = PhotoLibraryWriter()2425    private(set) var capabilities: DeviceCapabilities = .none26    private(set) var isConfigured = false27    private(set) var cameraAccessDenied = false28    private(set) var configurationError: Error?29    private(set) var lastCapturedIdentifier: String?30    /// Recipe proposed by a scene trigger — a proposal, never a silent switch.31    private(set) var proposedRecipe: Recipe?3233    var layout: ControlLayout = .photographe34    var subjectHint: String = ""3536    // Injected by AppModel.37    weak var recipes: RecipeStore?38    weak var projects: ProjectStore?39    var indexPipeline: IndexPipeline?40    var placeProvider: PlaceProvider?4142    /// Controls that are both in the layout and supported by the hardware.43    /// No dead buttons (CLAUDE.md §4).44    var visibleControls: [CaptureControl] {45        layout.visibleControls.filter { capabilities.supports($0) }46    }4748    func configureIfNeeded() async {49        // Coming back to the tab: the session was stopped on disappear,50        // restart it — otherwise the viewfinder stays black forever.51        guard !isConfigured else {52            service.start()53            await indexPipeline?.suspendForCapture()54            return55        }5657        switch AVCaptureDevice.authorizationStatus(for: .video) {58        case .notDetermined:59            guard await AVCaptureDevice.requestAccess(for: .video) else {60                cameraAccessDenied = true61                return62            }63        case .denied, .restricted:64            cameraAccessDenied = true65            return66        default:67            break68        }69        cameraAccessDenied = false7071        do {72            capabilities = try await service.configure(lens: controls.lens)73            controls.capabilities = capabilities74            isConfigured = true75            service.start()76            // The indexer never competes with the viewfinder (CLAUDE.md §9).77            await indexPipeline?.suspendForCapture()78        } catch {79            configurationError = error80        }81    }8283    func viewfinderDidDisappear() {84        service.stop()85        Task { await indexPipeline?.resumeAfterCapture() }86    }8788    func applyControls() {89        service.apply(controls.snapshot)90    }9192    func selectLens(_ lens: LensKind) async {93        guard capabilities.availableLenses.contains(lens) else { return }94        controls.lens = lens95        do {96            capabilities = try await service.switchLens(lens)97            controls.capabilities = capabilities98            applyControls()99        } catch {100            configurationError = error101        }102    }103104    func applyRecipe(_ recipe: Recipe) {105        recipes?.activeRecipeID = recipe.id106        controls.load(recipe.settings)107        if let projectName = recipe.autoProjectName, let projects {108            if let existing = projects.projects.first(where: { $0.name == projectName && $0.isActive }) {109                projects.activate(existing.id)110            } else {111                projects.declare(projectName)112            }113        }114        applyControls()115        proposedRecipe = nil116    }117118    func dismissProposedRecipe() {119        proposedRecipe = nil120    }121122    /// Scene triggers propose a recipe; the user decides (CLAUDE.md §6).123    func evaluateSceneTriggers(_ scene: SceneSignal) {124        guard proposedRecipe == nil, let recipes else { return }125        guard scene.isLowLight else { return }126        proposedRecipe = recipes.recipes.first {127            $0.sceneTrigger == .lowLight && $0.id != recipes.activeRecipeID128        }129    }130131    // MARK: - Shutter (sacred: enqueue and return, < 50 ms)132133    func capture() {134        let settings = controls.snapshot135        let context = CaptureContext(136            recipe: recipes?.activeRecipeID,137            project: projects?.activeProjectID,138            projectName: projects?.activeProject?.name,139            subjectHint: subjectHint.isEmpty ? nil : subjectHint,140            settings: settings,141            scene: .unknown,   // refined asynchronously below142            burstRole: nil,143            place: placeProvider?.currentPlace144        )145        let writer = writer146        let pipeline = indexPipeline147        let sceneTask = Task { await service.currentSceneSignal() }148149        service.capturePhoto(150            format: settings.format,151            flashMode: settings.flashMode ?? .auto,152            context: context153        ) { result in154            Task {155                guard case .success(var photo) = result else { return }156                photo.context.scene = await sceneTask.value157                guard let identifier = try? await writer.save(photo) else { return }158                // A photo taken in Focale arrives already understood:159                // the context enters the index immediately, no inference.160                await pipeline?.ingestCapturedPhoto(161                    localIdentifier: identifier,162                    context: photo.context163                )164                await MainActor.run { [weak self] in165                    self?.lastCapturedIdentifier = identifier166                }167            }168        }169    }170171    /// Freezes the current manual settings into a named, reusable recipe.172    @discardableResult173    func saveCurrentSettingsAsRecipe(named name: String) -> Recipe? {174        let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)175        guard !trimmed.isEmpty, let recipes else { return nil }176        let recipe = Recipe(name: trimmed, settings: controls.snapshot)177        recipes.add(recipe)178        recipes.activeRecipeID = recipe.id179        return recipe180    }181182    /// The hint can be added *after* the shot (never blocking before it).183    func applySubjectHintToLastCapture() {184        guard let lastCapturedIdentifier, !subjectHint.isEmpty else { return }185        let hint = subjectHint186        Task {187            await indexPipeline?.updateSubjectHint(188                localIdentifier: lastCapturedIdentifier,189                hint: hint190            )191        }192    }193}194