SPB Git

spb/focale Public

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