// // CaptureContext.swift // Focale // // Author: Simon-Pierre Boucher // Contact: contact@spboucher.ai // // The differentiating piece of the whole project (CLAUDE.md §4): // everything we know about a photo at the moment the shutter fires, // captured for free, with zero inference cost. // import Foundation /// Context recorded at shutter time. Written both to the local index and /// into the asset's own metadata, so the information survives an uninstall. struct CaptureContext: Codable, Sendable { var recipe: Recipe.ID? // active preset var project: Project.ID? // project declared by the user var projectName: String? // denormalized for metadata embedding var subjectHint: String? // typed or dictated before/after the shot var settings: ManualSettings // ISO, shutter, lens, focus var scene: SceneSignal // luminance, motion, distance (LiDAR if available) var burstRole: BurstRole? // selection within a burst var place: PlaceRef? // place, never raw coordinates /// Compact JSON used when embedding into asset metadata (EXIF UserComment). func metadataJSON() -> String? { let encoder = JSONEncoder() encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] guard let data = try? encoder.encode(self) else { return nil } return String(data: data, encoding: .utf8) } static func decode(fromMetadataJSON json: String) -> CaptureContext? { guard let data = json.data(using: .utf8) else { return nil } return try? JSONDecoder().decode(CaptureContext.self, from: data) } } /// Snapshot of the manual settings applied when the photo was taken. struct ManualSettings: Codable, Sendable, Hashable { var iso: Float? // nil = auto var shutterSeconds: Double? // nil = auto var lensPosition: Float? // 0...1, nil = autofocus var whiteBalanceKelvin: Float? // nil = auto WB var exposureBias: Float var lens: LensKind var zoomFactor: Double var format: CaptureFormat /// Optional so recipes saved before this field existed still decode. /// nil = auto. var flashMode: FlashMode? static let automatic = ManualSettings( iso: nil, shutterSeconds: nil, lensPosition: nil, whiteBalanceKelvin: nil, exposureBias: 0, lens: .wide, zoomFactor: 1.0, format: .heic, flashMode: nil ) } enum FlashMode: String, Codable, Sendable, CaseIterable { case auto, on, off var displayName: String { switch self { case .auto: "Flash auto" case .on: "Flash activé" case .off: "Flash désactivé" } } var symbolName: String { switch self { case .auto: "bolt.badge.a" case .on: "bolt.fill" case .off: "bolt.slash.fill" } } var next: FlashMode { switch self { case .auto: .on case .on: .off case .off: .auto } } } enum LensKind: String, Codable, Sendable, CaseIterable { case ultraWide, wide, telephoto /// User-facing name, in French (code stays in English). var displayName: String { switch self { case .ultraWide: "Ultra grand-angle" case .wide: "Grand-angle" case .telephoto: "Téléobjectif" } } } enum CaptureFormat: String, Codable, Sendable, CaseIterable { case heic case proRAW var displayName: String { switch self { case .heic: "HEIC" case .proRAW: "ProRAW" } } } enum BurstRole: String, Codable, Sendable { case selected // user's pick within the burst case member } /// A place, deliberately coarse. Never raw coordinates (CLAUDE.md §4). struct PlaceRef: Codable, Sendable { var name: String? // "Chalet", "Garage Marcel" var locality: String? // "Québec" var countryCode: String? // "CA" }