@preconcurrency import AVFoundation import Combine import UIKit import SwiftUI /// AVCaptureSession 기반 카메라 제어. @MainActor final class CameraService: NSObject, ObservableObject { enum State: Equatable { case idle case running case denied case failed } @Published var state: State = .idle @Published var isTorchOn = false let session = AVCaptureSession() private let photoOutput = AVCapturePhotoOutput() private var videoDevice: AVCaptureDevice? private var isConfigured = false private var captureHandler: ((Data?) -> Void)? func start() async { let granted: Bool switch AVCaptureDevice.authorizationStatus(for: .video) { case .authorized: granted = true case .notDetermined: granted = await AVCaptureDevice.requestAccess(for: .video) default: granted = false } guard granted else { state = .denied return } if !isConfigured { configure() } guard isConfigured else { state = .failed return } guard !session.isRunning else { state = .running return } let session = self.session await withCheckedContinuation { continuation in DispatchQueue.global(qos: .userInitiated).async { session.startRunning() continuation.resume() } } state = .running } func stop() { setTorch(on: false) guard session.isRunning else { return } let session = self.session DispatchQueue.global(qos: .userInitiated).async { session.stopRunning() } state = .idle } private func configure() { session.beginConfiguration() defer { session.commitConfiguration() } session.sessionPreset = .photo guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back), let input = try? AVCaptureDeviceInput(device: device), session.canAddInput(input), session.canAddOutput(photoOutput) else { print("카메라 구성 실패") return } session.addInput(input) session.addOutput(photoOutput) videoDevice = device isConfigured = true } // MARK: - 손전등 func toggleTorch() { setTorch(on: !isTorchOn) } private func setTorch(on: Bool) { guard let device = videoDevice, device.hasTorch else { return } do { try device.lockForConfiguration() device.torchMode = on ? .on : .off device.unlockForConfiguration() isTorchOn = on } catch { print("손전등 설정 실패: \(error)") } } // MARK: - 촬영 func capturePhoto(completion: @escaping (Data?) -> Void) { guard state == .running, captureHandler == nil else { completion(nil) return } captureHandler = completion let settings = AVCapturePhotoSettings() photoOutput.capturePhoto(with: settings, delegate: self) } fileprivate func finishCapture(_ data: Data?) { captureHandler?(data) captureHandler = nil } } extension CameraService: AVCapturePhotoCaptureDelegate { nonisolated func photoOutput( _ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error? ) { if let error { print("사진 촬영 실패: \(error)") } let data = photo.fileDataRepresentation() Task { @MainActor in self.finishCapture(data) } } } /// 카메라 미리보기 레이어를 감싸는 뷰. struct CameraPreview: UIViewRepresentable { let session: AVCaptureSession final class PreviewView: UIView { override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self } var previewLayer: AVCaptureVideoPreviewLayer { layer as! AVCaptureVideoPreviewLayer } } func makeUIView(context: Context) -> PreviewView { let view = PreviewView() view.previewLayer.session = session view.previewLayer.videoGravity = .resizeAspectFill return view } func updateUIView(_ uiView: PreviewView, context: Context) {} }