// // CaptureService.swift // Focale // // Author: Simon-Pierre Boucher // Contact: contact@spboucher.ai // // Owns the AVCaptureSession. All session work happens on a dedicated // serial queue, never on the main actor (CLAUDE.md §10). Configured // once; reconfigured inside begin/commitConfiguration. // @preconcurrency import AVFoundation import Foundation /// Serialized access to the capture session. `@unchecked Sendable` because /// every mutable member is confined to `sessionQueue`. final class CaptureService: NSObject, @unchecked Sendable { let session = AVCaptureSession() private let sessionQueue = DispatchQueue(label: "ai.spboucher.focale.capture.session") private let photoOutput = AVCapturePhotoOutput() private var videoInput: AVCaptureDeviceInput? private var device: AVCaptureDevice? /// Delegates retained for the duration of their capture, keyed by settings ID. private var inFlightProcessors: [Int64: PhotoCaptureProcessor] = [:] // MARK: - Configuration (once, off the main thread) func configure(lens: LensKind = .wide) async throws -> DeviceCapabilities { try await withCheckedThrowingContinuation { continuation in sessionQueue.async { do { let capabilities = try self.configureSession(lens: lens) continuation.resume(returning: capabilities) } catch { continuation.resume(throwing: error) } } } } func start() { sessionQueue.async { guard !self.session.isRunning else { return } self.session.startRunning() } } func stop() { sessionQueue.async { guard self.session.isRunning else { return } self.session.stopRunning() } } private func configureSession(lens: LensKind) throws -> DeviceCapabilities { session.beginConfiguration() defer { session.commitConfiguration() } session.sessionPreset = .photo if let videoInput { session.removeInput(videoInput) self.videoInput = nil } guard let device = Self.device(for: lens) ?? Self.device(for: .wide) else { throw CaptureError.noCamera } let input = try AVCaptureDeviceInput(device: device) guard session.canAddInput(input) else { throw CaptureError.cannotConfigure } session.addInput(input) videoInput = input self.device = device if !session.outputs.contains(photoOutput) { guard session.canAddOutput(photoOutput) else { throw CaptureError.cannotConfigure } session.addOutput(photoOutput) } photoOutput.maxPhotoQualityPrioritization = .quality // ProRAW must be enabled after the output joins a session. if photoOutput.isAppleProRAWSupported { photoOutput.isAppleProRAWEnabled = true } // Portrait-only app: without this, captures come out rotated. if let connection = photoOutput.connection(with: .video), connection.isVideoRotationAngleSupported(90) { connection.videoRotationAngle = 90 } return snapshotCapabilities(of: device) } /// Queried live — never assume anything about the iPhone model. private func snapshotCapabilities(of device: AVCaptureDevice) -> DeviceCapabilities { let format = device.activeFormat let lenses = LensKind.allCases.filter { Self.device(for: $0) != nil } let minShutter = max(format.minExposureDuration.seconds, 1.0 / 8000.0) let maxShutter = min(format.maxExposureDuration.seconds, 1.0) return DeviceCapabilities( availableLenses: lenses, supportsProRAW: photoOutput.isAppleProRAWSupported, isoRange: format.minISO...max(format.maxISO, format.minISO + 1), shutterRange: minShutter...max(maxShutter, minShutter * 2), exposureBiasRange: device.minExposureTargetBias...device.maxExposureTargetBias, maxWhiteBalanceGain: device.maxWhiteBalanceGain, maxZoomFactor: min(device.activeFormat.videoMaxZoomFactor, 10.0), supportsManualFocus: device.isLockingFocusWithCustomLensPositionSupported, hasDepthCapture: !format.supportedDepthDataFormats.isEmpty, hasFlash: device.hasFlash ) } private static func device(for lens: LensKind) -> AVCaptureDevice? { let deviceType: AVCaptureDevice.DeviceType = switch lens { case .ultraWide: .builtInUltraWideCamera case .wide: .builtInWideAngleCamera case .telephoto: .builtInTelephotoCamera } return AVCaptureDevice.DiscoverySession( deviceTypes: [deviceType], mediaType: .video, position: .back ).devices.first } // MARK: - Manual controls /// Applies a settings snapshot. Any nil field returns that control to auto. func apply(_ settings: ManualSettings) { sessionQueue.async { guard let device = self.device else { return } do { try device.lockForConfiguration() defer { device.unlockForConfiguration() } self.applyExposure(settings, to: device) self.applyFocus(settings, to: device) self.applyWhiteBalance(settings, to: device) let zoom = settings.zoomFactor.clamped( to: 1.0...device.activeFormat.videoMaxZoomFactor ) if abs(device.videoZoomFactor - zoom) > 0.01 { device.videoZoomFactor = zoom } } catch { // Configuration lock failed; controls stay at their last value. } } } private func applyExposure(_ settings: ManualSettings, to device: AVCaptureDevice) { let format = device.activeFormat if let iso = settings.iso, let shutter = settings.shutterSeconds, device.isExposureModeSupported(.custom) { let clampedISO = iso.clamped(to: format.minISO...format.maxISO) let duration = CMTime( seconds: shutter.clamped( to: format.minExposureDuration.seconds...format.maxExposureDuration.seconds ), preferredTimescale: 1_000_000_000 ) device.setExposureModeCustom(duration: duration, iso: clampedISO) } else if device.isExposureModeSupported(.continuousAutoExposure) { device.exposureMode = .continuousAutoExposure let bias = settings.exposureBias.clamped( to: device.minExposureTargetBias...device.maxExposureTargetBias ) device.setExposureTargetBias(bias) } } private func applyFocus(_ settings: ManualSettings, to device: AVCaptureDevice) { if let lensPosition = settings.lensPosition, device.isLockingFocusWithCustomLensPositionSupported { device.setFocusModeLocked(lensPosition: lensPosition.clamped(to: 0...1)) } else if device.isFocusModeSupported(.continuousAutoFocus) { device.focusMode = .continuousAutoFocus } } private func applyWhiteBalance(_ settings: ManualSettings, to device: AVCaptureDevice) { if let kelvin = settings.whiteBalanceKelvin, device.isLockingWhiteBalanceWithCustomDeviceGainsSupported { let values = AVCaptureDevice.WhiteBalanceTemperatureAndTintValues( temperature: kelvin, tint: 0 ) var gains = device.deviceWhiteBalanceGains(for: values) let maxGain = device.maxWhiteBalanceGain gains.redGain = gains.redGain.clamped(to: 1...maxGain) gains.greenGain = gains.greenGain.clamped(to: 1...maxGain) gains.blueGain = gains.blueGain.clamped(to: 1...maxGain) device.setWhiteBalanceModeLocked(with: gains) } else if device.isWhiteBalanceModeSupported(.continuousAutoWhiteBalance) { device.whiteBalanceMode = .continuousAutoWhiteBalance } } func switchLens(_ lens: LensKind) async throws -> DeviceCapabilities { try await withCheckedThrowingContinuation { continuation in sessionQueue.async { do { continuation.resume(returning: try self.configureSession(lens: lens)) } catch { continuation.resume(throwing: error) } } } } // MARK: - Scene signal (raw sensor reads, no inference) func currentSceneSignal() async -> SceneSignal { await withCheckedContinuation { continuation in sessionQueue.async { guard let device = self.device else { continuation.resume(returning: .unknown) return } let ev = SceneSignal.estimateEV( iso: device.iso, shutterSeconds: device.exposureDuration.seconds ) let signal = SceneSignal( luminanceEV: ev, motion: .unknown, subjectDistance: nil, isLowLight: (ev ?? 10) < 4 ) continuation.resume(returning: signal) } } } // MARK: - Capture /// The shutter path is sacred (CLAUDE.md §4): this enqueues the capture /// and returns immediately. All processing and disk I/O are deferred to /// the photo output's own callbacks. func capturePhoto( format: CaptureFormat, flashMode: FlashMode, context: CaptureContext, completion: @escaping @Sendable (Result) -> Void ) { sessionQueue.async { let settings = self.makePhotoSettings(for: format, flashMode: flashMode) let processor = PhotoCaptureProcessor(context: context) { [weak self] result in if let self { self.sessionQueue.async { self.inFlightProcessors[settings.uniqueID] = nil } } completion(result) } self.inFlightProcessors[settings.uniqueID] = processor self.photoOutput.capturePhoto(with: settings, delegate: processor) } } private func makePhotoSettings( for format: CaptureFormat, flashMode: FlashMode ) -> AVCapturePhotoSettings { let settings: AVCapturePhotoSettings if format == .proRAW, photoOutput.isAppleProRAWEnabled, let rawFormat = photoOutput.availableRawPhotoPixelFormatTypes.first( where: { AVCapturePhotoOutput.isAppleProRAWPixelFormat($0) } ) { settings = AVCapturePhotoSettings( rawPixelFormatType: rawFormat, processedFormat: [AVVideoCodecKey: AVVideoCodecType.hevc] ) } else if photoOutput.availablePhotoCodecTypes.contains(.hevc) { settings = AVCapturePhotoSettings(format: [AVVideoCodecKey: AVVideoCodecType.hevc]) } else { settings = AVCapturePhotoSettings() } settings.photoQualityPrioritization = .quality let avFlashMode = flashMode.avFlashMode if photoOutput.supportedFlashModes.contains(avFlashMode) { settings.flashMode = avFlashMode } return settings } } enum CaptureError: Error { case noCamera case cannotConfigure case captureFailed } extension FlashMode { var avFlashMode: AVCaptureDevice.FlashMode { switch self { case .auto: .auto case .on: .on case .off: .off } } } extension Comparable { func clamped(to range: ClosedRange) -> Self { min(max(self, range.lowerBound), range.upperBound) } }