spb/focale Public
Swift 100%
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