SPB Git

spb/focale Public

Swift 100%
5.8 KB · 162 lines swift
Raw Blame History
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