spb/focale Public
Swift 100%
1//2// CaptureService.swift3// Focale4//5// Author: Simon-Pierre Boucher6// Contact: contact@spboucher.ai7//8// Owns the AVCaptureSession. All session work happens on a dedicated9// serial queue, never on the main actor (CLAUDE.md §10). Configured10// once; reconfigured inside begin/commitConfiguration.11//1213@preconcurrency import AVFoundation14import Foundation1516/// Serialized access to the capture session. `@unchecked Sendable` because17/// every mutable member is confined to `sessionQueue`.18final class CaptureService: NSObject, @unchecked Sendable {1920 let session = AVCaptureSession()2122 private let sessionQueue = DispatchQueue(label: "ai.spboucher.focale.capture.session")23 private let photoOutput = AVCapturePhotoOutput()24 private var videoInput: AVCaptureDeviceInput?25 private var device: AVCaptureDevice?26 /// Delegates retained for the duration of their capture, keyed by settings ID.27 private var inFlightProcessors: [Int64: PhotoCaptureProcessor] = [:]2829 // MARK: - Configuration (once, off the main thread)3031 func configure(lens: LensKind = .wide) async throws -> DeviceCapabilities {32 try await withCheckedThrowingContinuation { continuation in33 sessionQueue.async {34 do {35 let capabilities = try self.configureSession(lens: lens)36 continuation.resume(returning: capabilities)37 } catch {38 continuation.resume(throwing: error)39 }40 }41 }42 }4344 func start() {45 sessionQueue.async {46 guard !self.session.isRunning else { return }47 self.session.startRunning()48 }49 }5051 func stop() {52 sessionQueue.async {53 guard self.session.isRunning else { return }54 self.session.stopRunning()55 }56 }5758 private func configureSession(lens: LensKind) throws -> DeviceCapabilities {59 session.beginConfiguration()60 defer { session.commitConfiguration() }6162 session.sessionPreset = .photo6364 if let videoInput {65 session.removeInput(videoInput)66 self.videoInput = nil67 }6869 guard let device = Self.device(for: lens) ?? Self.device(for: .wide) else {70 throw CaptureError.noCamera71 }72 let input = try AVCaptureDeviceInput(device: device)73 guard session.canAddInput(input) else { throw CaptureError.cannotConfigure }74 session.addInput(input)75 videoInput = input76 self.device = device7778 if !session.outputs.contains(photoOutput) {79 guard session.canAddOutput(photoOutput) else { throw CaptureError.cannotConfigure }80 session.addOutput(photoOutput)81 }82 photoOutput.maxPhotoQualityPrioritization = .quality83 // ProRAW must be enabled after the output joins a session.84 if photoOutput.isAppleProRAWSupported {85 photoOutput.isAppleProRAWEnabled = true86 }87 // Portrait-only app: without this, captures come out rotated.88 if let connection = photoOutput.connection(with: .video),89 connection.isVideoRotationAngleSupported(90) {90 connection.videoRotationAngle = 9091 }9293 return snapshotCapabilities(of: device)94 }9596 /// Queried live — never assume anything about the iPhone model.97 private func snapshotCapabilities(of device: AVCaptureDevice) -> DeviceCapabilities {98 let format = device.activeFormat99 let lenses = LensKind.allCases.filter { Self.device(for: $0) != nil }100 let minShutter = max(format.minExposureDuration.seconds, 1.0 / 8000.0)101 let maxShutter = min(format.maxExposureDuration.seconds, 1.0)102103 return DeviceCapabilities(104 availableLenses: lenses,105 supportsProRAW: photoOutput.isAppleProRAWSupported,106 isoRange: format.minISO...max(format.maxISO, format.minISO + 1),107 shutterRange: minShutter...max(maxShutter, minShutter * 2),108 exposureBiasRange: device.minExposureTargetBias...device.maxExposureTargetBias,109 maxWhiteBalanceGain: device.maxWhiteBalanceGain,110 maxZoomFactor: min(device.activeFormat.videoMaxZoomFactor, 10.0),111 supportsManualFocus: device.isLockingFocusWithCustomLensPositionSupported,112 hasDepthCapture: !format.supportedDepthDataFormats.isEmpty,113 hasFlash: device.hasFlash114 )115 }116117 private static func device(for lens: LensKind) -> AVCaptureDevice? {118 let deviceType: AVCaptureDevice.DeviceType = switch lens {119 case .ultraWide: .builtInUltraWideCamera120 case .wide: .builtInWideAngleCamera121 case .telephoto: .builtInTelephotoCamera122 }123 return AVCaptureDevice.DiscoverySession(124 deviceTypes: [deviceType], mediaType: .video, position: .back125 ).devices.first126 }127128 // MARK: - Manual controls129130 /// Applies a settings snapshot. Any nil field returns that control to auto.131 func apply(_ settings: ManualSettings) {132 sessionQueue.async {133 guard let device = self.device else { return }134 do {135 try device.lockForConfiguration()136 defer { device.unlockForConfiguration() }137138 self.applyExposure(settings, to: device)139 self.applyFocus(settings, to: device)140 self.applyWhiteBalance(settings, to: device)141142 let zoom = settings.zoomFactor.clamped(143 to: 1.0...device.activeFormat.videoMaxZoomFactor144 )145 if abs(device.videoZoomFactor - zoom) > 0.01 {146 device.videoZoomFactor = zoom147 }148 } catch {149 // Configuration lock failed; controls stay at their last value.150 }151 }152 }153154 private func applyExposure(_ settings: ManualSettings, to device: AVCaptureDevice) {155 let format = device.activeFormat156 if let iso = settings.iso, let shutter = settings.shutterSeconds,157 device.isExposureModeSupported(.custom) {158 let clampedISO = iso.clamped(to: format.minISO...format.maxISO)159 let duration = CMTime(160 seconds: shutter.clamped(161 to: format.minExposureDuration.seconds...format.maxExposureDuration.seconds162 ),163 preferredTimescale: 1_000_000_000164 )165 device.setExposureModeCustom(duration: duration, iso: clampedISO)166 } else if device.isExposureModeSupported(.continuousAutoExposure) {167 device.exposureMode = .continuousAutoExposure168 let bias = settings.exposureBias.clamped(169 to: device.minExposureTargetBias...device.maxExposureTargetBias170 )171 device.setExposureTargetBias(bias)172 }173 }174175 private func applyFocus(_ settings: ManualSettings, to device: AVCaptureDevice) {176 if let lensPosition = settings.lensPosition,177 device.isLockingFocusWithCustomLensPositionSupported {178 device.setFocusModeLocked(lensPosition: lensPosition.clamped(to: 0...1))179 } else if device.isFocusModeSupported(.continuousAutoFocus) {180 device.focusMode = .continuousAutoFocus181 }182 }183184 private func applyWhiteBalance(_ settings: ManualSettings, to device: AVCaptureDevice) {185 if let kelvin = settings.whiteBalanceKelvin, device.isLockingWhiteBalanceWithCustomDeviceGainsSupported {186 let values = AVCaptureDevice.WhiteBalanceTemperatureAndTintValues(187 temperature: kelvin, tint: 0188 )189 var gains = device.deviceWhiteBalanceGains(for: values)190 let maxGain = device.maxWhiteBalanceGain191 gains.redGain = gains.redGain.clamped(to: 1...maxGain)192 gains.greenGain = gains.greenGain.clamped(to: 1...maxGain)193 gains.blueGain = gains.blueGain.clamped(to: 1...maxGain)194 device.setWhiteBalanceModeLocked(with: gains)195 } else if device.isWhiteBalanceModeSupported(.continuousAutoWhiteBalance) {196 device.whiteBalanceMode = .continuousAutoWhiteBalance197 }198 }199200 func switchLens(_ lens: LensKind) async throws -> DeviceCapabilities {201 try await withCheckedThrowingContinuation { continuation in202 sessionQueue.async {203 do {204 continuation.resume(returning: try self.configureSession(lens: lens))205 } catch {206 continuation.resume(throwing: error)207 }208 }209 }210 }211212 // MARK: - Scene signal (raw sensor reads, no inference)213214 func currentSceneSignal() async -> SceneSignal {215 await withCheckedContinuation { continuation in216 sessionQueue.async {217 guard let device = self.device else {218 continuation.resume(returning: .unknown)219 return220 }221 let ev = SceneSignal.estimateEV(222 iso: device.iso,223 shutterSeconds: device.exposureDuration.seconds224 )225 let signal = SceneSignal(226 luminanceEV: ev,227 motion: .unknown,228 subjectDistance: nil,229 isLowLight: (ev ?? 10) < 4230 )231 continuation.resume(returning: signal)232 }233 }234 }235236 // MARK: - Capture237238 /// The shutter path is sacred (CLAUDE.md §4): this enqueues the capture239 /// and returns immediately. All processing and disk I/O are deferred to240 /// the photo output's own callbacks.241 func capturePhoto(242 format: CaptureFormat,243 flashMode: FlashMode,244 context: CaptureContext,245 completion: @escaping @Sendable (Result<CapturedPhoto, Error>) -> Void246 ) {247 sessionQueue.async {248 let settings = self.makePhotoSettings(for: format, flashMode: flashMode)249 let processor = PhotoCaptureProcessor(context: context) { [weak self] result in250 if let self {251 self.sessionQueue.async {252 self.inFlightProcessors[settings.uniqueID] = nil253 }254 }255 completion(result)256 }257 self.inFlightProcessors[settings.uniqueID] = processor258 self.photoOutput.capturePhoto(with: settings, delegate: processor)259 }260 }261262 private func makePhotoSettings(263 for format: CaptureFormat, flashMode: FlashMode264 ) -> AVCapturePhotoSettings {265 let settings: AVCapturePhotoSettings266 if format == .proRAW,267 photoOutput.isAppleProRAWEnabled,268 let rawFormat = photoOutput.availableRawPhotoPixelFormatTypes.first(269 where: { AVCapturePhotoOutput.isAppleProRAWPixelFormat($0) }270 ) {271 settings = AVCapturePhotoSettings(272 rawPixelFormatType: rawFormat,273 processedFormat: [AVVideoCodecKey: AVVideoCodecType.hevc]274 )275 } else if photoOutput.availablePhotoCodecTypes.contains(.hevc) {276 settings = AVCapturePhotoSettings(format: [AVVideoCodecKey: AVVideoCodecType.hevc])277 } else {278 settings = AVCapturePhotoSettings()279 }280 settings.photoQualityPrioritization = .quality281 let avFlashMode = flashMode.avFlashMode282 if photoOutput.supportedFlashModes.contains(avFlashMode) {283 settings.flashMode = avFlashMode284 }285 return settings286 }287}288289enum CaptureError: Error {290 case noCamera291 case cannotConfigure292 case captureFailed293}294295extension FlashMode {296 var avFlashMode: AVCaptureDevice.FlashMode {297 switch self {298 case .auto: .auto299 case .on: .on300 case .off: .off301 }302 }303}304305extension Comparable {306 func clamped(to range: ClosedRange<Self>) -> Self {307 min(max(self, range.lowerBound), range.upperBound)308 }309}310