// // CameraModel.swift // Focale // // Author: Simon-Pierre Boucher // Contact: contact@spboucher.ai // // Main-actor model behind the capture screen. The shutter path here does // the minimum: snapshot context, enqueue the capture, return. Everything // else (saving, indexing) happens in detached work afterwards. // import AVFoundation import Foundation import Observation @MainActor @Observable final class CameraModel { let service = CaptureService() let controls = ManualControls() private let writer = PhotoLibraryWriter() private(set) var capabilities: DeviceCapabilities = .none private(set) var isConfigured = false private(set) var cameraAccessDenied = false private(set) var configurationError: Error? private(set) var lastCapturedIdentifier: String? /// Recipe proposed by a scene trigger — a proposal, never a silent switch. private(set) var proposedRecipe: Recipe? var layout: ControlLayout = .photographe var subjectHint: String = "" // Injected by AppModel. weak var recipes: RecipeStore? weak var projects: ProjectStore? var indexPipeline: IndexPipeline? var placeProvider: PlaceProvider? /// Controls that are both in the layout and supported by the hardware. /// No dead buttons (CLAUDE.md §4). var visibleControls: [CaptureControl] { layout.visibleControls.filter { capabilities.supports($0) } } func configureIfNeeded() async { // Coming back to the tab: the session was stopped on disappear, // restart it — otherwise the viewfinder stays black forever. guard !isConfigured else { service.start() await indexPipeline?.suspendForCapture() return } switch AVCaptureDevice.authorizationStatus(for: .video) { case .notDetermined: guard await AVCaptureDevice.requestAccess(for: .video) else { cameraAccessDenied = true return } case .denied, .restricted: cameraAccessDenied = true return default: break } cameraAccessDenied = false do { capabilities = try await service.configure(lens: controls.lens) controls.capabilities = capabilities isConfigured = true service.start() // The indexer never competes with the viewfinder (CLAUDE.md §9). await indexPipeline?.suspendForCapture() } catch { configurationError = error } } func viewfinderDidDisappear() { service.stop() Task { await indexPipeline?.resumeAfterCapture() } } func applyControls() { service.apply(controls.snapshot) } func selectLens(_ lens: LensKind) async { guard capabilities.availableLenses.contains(lens) else { return } controls.lens = lens do { capabilities = try await service.switchLens(lens) controls.capabilities = capabilities applyControls() } catch { configurationError = error } } func applyRecipe(_ recipe: Recipe) { recipes?.activeRecipeID = recipe.id controls.load(recipe.settings) if let projectName = recipe.autoProjectName, let projects { if let existing = projects.projects.first(where: { $0.name == projectName && $0.isActive }) { projects.activate(existing.id) } else { projects.declare(projectName) } } applyControls() proposedRecipe = nil } func dismissProposedRecipe() { proposedRecipe = nil } /// Scene triggers propose a recipe; the user decides (CLAUDE.md §6). func evaluateSceneTriggers(_ scene: SceneSignal) { guard proposedRecipe == nil, let recipes else { return } guard scene.isLowLight else { return } proposedRecipe = recipes.recipes.first { $0.sceneTrigger == .lowLight && $0.id != recipes.activeRecipeID } } // MARK: - Shutter (sacred: enqueue and return, < 50 ms) func capture() { let settings = controls.snapshot let context = CaptureContext( recipe: recipes?.activeRecipeID, project: projects?.activeProjectID, projectName: projects?.activeProject?.name, subjectHint: subjectHint.isEmpty ? nil : subjectHint, settings: settings, scene: .unknown, // refined asynchronously below burstRole: nil, place: placeProvider?.currentPlace ) let writer = writer let pipeline = indexPipeline let sceneTask = Task { await service.currentSceneSignal() } service.capturePhoto( format: settings.format, flashMode: settings.flashMode ?? .auto, context: context ) { result in Task { guard case .success(var photo) = result else { return } photo.context.scene = await sceneTask.value guard let identifier = try? await writer.save(photo) else { return } // A photo taken in Focale arrives already understood: // the context enters the index immediately, no inference. await pipeline?.ingestCapturedPhoto( localIdentifier: identifier, context: photo.context ) await MainActor.run { [weak self] in self?.lastCapturedIdentifier = identifier } } } } /// Freezes the current manual settings into a named, reusable recipe. @discardableResult func saveCurrentSettingsAsRecipe(named name: String) -> Recipe? { let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty, let recipes else { return nil } let recipe = Recipe(name: trimmed, settings: controls.snapshot) recipes.add(recipe) recipes.activeRecipeID = recipe.id return recipe } /// The hint can be added *after* the shot (never blocking before it). func applySubjectHintToLastCapture() { guard let lastCapturedIdentifier, !subjectHint.isEmpty else { return } let hint = subjectHint Task { await indexPipeline?.updateSubjectHint( localIdentifier: lastCapturedIdentifier, hint: hint ) } } }