spb/focale Public
Swift 100%
1//2// ManualControls.swift3// Focale4//5// Author: Simon-Pierre Boucher6// Contact: contact@spboucher.ai7//8// UI-side model of the manual controls. Each control is either auto or9// holds a manual value, clamped to what the device actually supports.10//1112import Foundation13import Observation1415@MainActor16@Observable17final class ManualControls {1819 var capabilities: DeviceCapabilities = .none {20 didSet { clampAll() }21 }2223 // nil = auto for each of these.24 var iso: Float?25 var shutterSeconds: Double?26 var lensPosition: Float?27 var whiteBalanceKelvin: Float?28 var exposureBias: Float = 029 var lens: LensKind = .wide30 var zoomFactor: Double = 1.031 var format: CaptureFormat = .heic32 var flashMode: FlashMode = .auto3334 /// Immutable snapshot for the capture context and the session queue.35 var snapshot: ManualSettings {36 ManualSettings(37 iso: iso,38 shutterSeconds: shutterSeconds,39 lensPosition: lensPosition,40 whiteBalanceKelvin: whiteBalanceKelvin,41 exposureBias: exposureBias,42 lens: lens,43 zoomFactor: zoomFactor,44 format: format,45 flashMode: flashMode46 )47 }4849 func load(_ settings: ManualSettings) {50 iso = settings.iso51 shutterSeconds = settings.shutterSeconds52 lensPosition = settings.lensPosition53 whiteBalanceKelvin = settings.whiteBalanceKelvin54 exposureBias = settings.exposureBias55 lens = settings.lens56 zoomFactor = settings.zoomFactor57 format = settings.format58 flashMode = settings.flashMode ?? .auto59 clampAll()60 }6162 func resetToAuto() {63 iso = nil64 shutterSeconds = nil65 lensPosition = nil66 whiteBalanceKelvin = nil67 exposureBias = 068 }6970 /// Normalized (0...1) accessor used by dials and mapped gestures.71 func normalizedValue(for control: CaptureControl) -> Double {72 switch control {73 case .iso:74 let range = capabilities.isoRange75 let value = iso ?? range.lowerBound76 return normalize(Double(value), in: Double(range.lowerBound)...Double(range.upperBound))77 case .shutterSpeed:78 // Log scale: shutter perception is logarithmic.79 let range = capabilities.shutterRange80 let value = shutterSeconds ?? range.lowerBound81 return normalize(log2(value), in: log2(range.lowerBound)...log2(range.upperBound))82 case .exposureBias:83 let range = capabilities.exposureBiasRange84 return normalize(Double(exposureBias), in: Double(range.lowerBound)...Double(range.upperBound))85 case .focus:86 return Double(lensPosition ?? 0.5)87 case .whiteBalance:88 return normalize(Double(whiteBalanceKelvin ?? 5500), in: 2500...8000)89 case .zoom:90 return normalize(zoomFactor, in: 1...capabilities.maxZoomFactor)91 }92 }9394 func setNormalizedValue(_ normalized: Double, for control: CaptureControl) {95 let t = normalized.clamped(to: 0...1)96 switch control {97 case .iso:98 let range = capabilities.isoRange99 iso = Float(lerp(t, Double(range.lowerBound)...Double(range.upperBound)))100 case .shutterSpeed:101 let range = capabilities.shutterRange102 shutterSeconds = pow(2, lerp(t, log2(range.lowerBound)...log2(range.upperBound)))103 case .exposureBias:104 let range = capabilities.exposureBiasRange105 exposureBias = Float(lerp(t, Double(range.lowerBound)...Double(range.upperBound)))106 case .focus:107 lensPosition = Float(t)108 case .whiteBalance:109 whiteBalanceKelvin = Float(lerp(t, 2500...8000))110 case .zoom:111 zoomFactor = lerp(t, 1...max(capabilities.maxZoomFactor, 1.01))112 }113 }114115 /// User-facing formatted value for a control (French display conventions).116 func displayValue(for control: CaptureControl) -> String {117 switch control {118 case .iso:119 if let iso { return "ISO \(Int(iso))" }120 return "ISO auto"121 case .shutterSpeed:122 guard let shutterSeconds else { return "Auto" }123 if shutterSeconds >= 0.25 {124 return String(format: "%.1f s", shutterSeconds)125 }126 return "1/\(Int((1.0 / shutterSeconds).rounded()))"127 case .exposureBias:128 return String(format: "%+.1f IL", exposureBias)129 case .focus:130 if let lensPosition { return String(format: "MAP %.2f", lensPosition) }131 return "AF"132 case .whiteBalance:133 if let whiteBalanceKelvin { return "\(Int(whiteBalanceKelvin)) K" }134 return "BB auto"135 case .zoom:136 return String(format: "%.1f×", zoomFactor)137 }138 }139140 // MARK: - Helpers141142 private func clampAll() {143 if let value = iso { iso = value.clamped(to: capabilities.isoRange) }144 if let value = shutterSeconds { shutterSeconds = value.clamped(to: capabilities.shutterRange) }145 if let value = lensPosition { lensPosition = value.clamped(to: 0...1) }146 exposureBias = exposureBias.clamped(to: capabilities.exposureBiasRange)147 zoomFactor = zoomFactor.clamped(to: 1...max(capabilities.maxZoomFactor, 1))148 if !capabilities.availableLenses.contains(lens) { lens = .wide }149 if format == .proRAW && !capabilities.supportsProRAW { format = .heic }150 }151152 private func normalize(_ value: Double, in range: ClosedRange<Double>) -> Double {153 guard range.upperBound > range.lowerBound else { return 0 }154 return ((value - range.lowerBound) / (range.upperBound - range.lowerBound))155 .clamped(to: 0...1)156 }157158 private func lerp(_ t: Double, _ range: ClosedRange<Double>) -> Double {159 range.lowerBound + t * (range.upperBound - range.lowerBound)160 }161}162