// // ManualControls.swift // Focale // // Author: Simon-Pierre Boucher // Contact: contact@spboucher.ai // // UI-side model of the manual controls. Each control is either auto or // holds a manual value, clamped to what the device actually supports. // import Foundation import Observation @MainActor @Observable final class ManualControls { var capabilities: DeviceCapabilities = .none { didSet { clampAll() } } // nil = auto for each of these. var iso: Float? var shutterSeconds: Double? var lensPosition: Float? var whiteBalanceKelvin: Float? var exposureBias: Float = 0 var lens: LensKind = .wide var zoomFactor: Double = 1.0 var format: CaptureFormat = .heic var flashMode: FlashMode = .auto /// Immutable snapshot for the capture context and the session queue. var snapshot: ManualSettings { ManualSettings( iso: iso, shutterSeconds: shutterSeconds, lensPosition: lensPosition, whiteBalanceKelvin: whiteBalanceKelvin, exposureBias: exposureBias, lens: lens, zoomFactor: zoomFactor, format: format, flashMode: flashMode ) } func load(_ settings: ManualSettings) { iso = settings.iso shutterSeconds = settings.shutterSeconds lensPosition = settings.lensPosition whiteBalanceKelvin = settings.whiteBalanceKelvin exposureBias = settings.exposureBias lens = settings.lens zoomFactor = settings.zoomFactor format = settings.format flashMode = settings.flashMode ?? .auto clampAll() } func resetToAuto() { iso = nil shutterSeconds = nil lensPosition = nil whiteBalanceKelvin = nil exposureBias = 0 } /// Normalized (0...1) accessor used by dials and mapped gestures. func normalizedValue(for control: CaptureControl) -> Double { switch control { case .iso: let range = capabilities.isoRange let value = iso ?? range.lowerBound return normalize(Double(value), in: Double(range.lowerBound)...Double(range.upperBound)) case .shutterSpeed: // Log scale: shutter perception is logarithmic. let range = capabilities.shutterRange let value = shutterSeconds ?? range.lowerBound return normalize(log2(value), in: log2(range.lowerBound)...log2(range.upperBound)) case .exposureBias: let range = capabilities.exposureBiasRange return normalize(Double(exposureBias), in: Double(range.lowerBound)...Double(range.upperBound)) case .focus: return Double(lensPosition ?? 0.5) case .whiteBalance: return normalize(Double(whiteBalanceKelvin ?? 5500), in: 2500...8000) case .zoom: return normalize(zoomFactor, in: 1...capabilities.maxZoomFactor) } } func setNormalizedValue(_ normalized: Double, for control: CaptureControl) { let t = normalized.clamped(to: 0...1) switch control { case .iso: let range = capabilities.isoRange iso = Float(lerp(t, Double(range.lowerBound)...Double(range.upperBound))) case .shutterSpeed: let range = capabilities.shutterRange shutterSeconds = pow(2, lerp(t, log2(range.lowerBound)...log2(range.upperBound))) case .exposureBias: let range = capabilities.exposureBiasRange exposureBias = Float(lerp(t, Double(range.lowerBound)...Double(range.upperBound))) case .focus: lensPosition = Float(t) case .whiteBalance: whiteBalanceKelvin = Float(lerp(t, 2500...8000)) case .zoom: zoomFactor = lerp(t, 1...max(capabilities.maxZoomFactor, 1.01)) } } /// User-facing formatted value for a control (French display conventions). func displayValue(for control: CaptureControl) -> String { switch control { case .iso: if let iso { return "ISO \(Int(iso))" } return "ISO auto" case .shutterSpeed: guard let shutterSeconds else { return "Auto" } if shutterSeconds >= 0.25 { return String(format: "%.1f s", shutterSeconds) } return "1/\(Int((1.0 / shutterSeconds).rounded()))" case .exposureBias: return String(format: "%+.1f IL", exposureBias) case .focus: if let lensPosition { return String(format: "MAP %.2f", lensPosition) } return "AF" case .whiteBalance: if let whiteBalanceKelvin { return "\(Int(whiteBalanceKelvin)) K" } return "BB auto" case .zoom: return String(format: "%.1f×", zoomFactor) } } // MARK: - Helpers private func clampAll() { if let value = iso { iso = value.clamped(to: capabilities.isoRange) } if let value = shutterSeconds { shutterSeconds = value.clamped(to: capabilities.shutterRange) } if let value = lensPosition { lensPosition = value.clamped(to: 0...1) } exposureBias = exposureBias.clamped(to: capabilities.exposureBiasRange) zoomFactor = zoomFactor.clamped(to: 1...max(capabilities.maxZoomFactor, 1)) if !capabilities.availableLenses.contains(lens) { lens = .wide } if format == .proRAW && !capabilities.supportsProRAW { format = .heic } } private func normalize(_ value: Double, in range: ClosedRange) -> Double { guard range.upperBound > range.lowerBound else { return 0 } return ((value - range.lowerBound) / (range.upperBound - range.lowerBound)) .clamped(to: 0...1) } private func lerp(_ t: Double, _ range: ClosedRange) -> Double { range.lowerBound + t * (range.upperBound - range.lowerBound) } }