CameraService.swift (27639B)
1 // 2 // CameraService.swift 3 // Campus 4 // 5 // Created by Suhail Saqan on 8/5/23. 6 // 7 8 import Foundation 9 import Combine 10 import AVFoundation 11 import Photos 12 import UIKit 13 14 public struct Thumbnail: Identifiable, Equatable { 15 public var id: String 16 public var type: CameraMediaType 17 public var url: URL 18 19 public init(id: String = UUID().uuidString, type: CameraMediaType, url: URL) { 20 self.id = id 21 self.type = type 22 self.url = url 23 } 24 25 public var thumbnailImage: UIImage? { 26 switch type { 27 case .image: 28 return ImageResizer(targetWidth: 100).resize(at: url) 29 case .video: 30 return generateVideoThumbnail(for: url) 31 } 32 } 33 } 34 35 public struct AlertError { 36 public var title: String = "" 37 public var message: String = "" 38 public var primaryButtonTitle = "Accept" 39 public var secondaryButtonTitle: String? 40 public var primaryAction: (() -> ())? 41 public var secondaryAction: (() -> ())? 42 43 public init(title: String = "", message: String = "", primaryButtonTitle: String = "Accept", secondaryButtonTitle: String? = nil, primaryAction: (() -> ())? = nil, secondaryAction: (() -> ())? = nil) { 44 self.title = title 45 self.message = message 46 self.primaryAction = primaryAction 47 self.primaryButtonTitle = primaryButtonTitle 48 self.secondaryAction = secondaryAction 49 } 50 } 51 52 func generateVideoThumbnail(for videoURL: URL) -> UIImage? { 53 let asset = AVAsset(url: videoURL) 54 let imageGenerator = AVAssetImageGenerator(asset: asset) 55 imageGenerator.appliesPreferredTrackTransform = true 56 57 do { 58 let cgImage = try imageGenerator.copyCGImage(at: .zero, actualTime: nil) 59 return UIImage(cgImage: cgImage) 60 } catch { 61 print("Error generating thumbnail: \(error)") 62 return nil 63 } 64 } 65 66 public enum CameraMediaType { 67 case image 68 case video 69 } 70 71 public struct MediaItem { 72 let url: URL 73 let type: CameraMediaType 74 } 75 76 public class CameraService: NSObject, Identifiable { 77 public let session = AVCaptureSession() 78 79 public var isSessionRunning = false 80 public var isConfigured = false 81 var setupResult: SessionSetupResult = .success 82 83 public var alertError: AlertError = AlertError() 84 85 @Published public var flashMode: AVCaptureDevice.FlashMode = .off 86 @Published public var shouldShowAlertView = false 87 @Published public var isPhotoProcessing = false 88 @Published public var captureMode: CameraMediaType = .image 89 @Published public var isRecording: Bool = false 90 91 @Published public var willCapturePhoto = false 92 @Published public var isCameraButtonDisabled = false 93 @Published public var isCameraUnavailable = false 94 @Published public var thumbnail: Thumbnail? 95 @Published public var mediaItems: [MediaItem] = [] 96 97 public let sessionQueue = DispatchQueue(label: "io.damus.camera") 98 99 @objc dynamic public var videoDeviceInput: AVCaptureDeviceInput! 100 @objc dynamic public var audioDeviceInput: AVCaptureDeviceInput! 101 102 public let videoDeviceDiscoverySession = AVCaptureDevice.DiscoverySession(deviceTypes: [.builtInWideAngleCamera, .builtInDualCamera, .builtInTrueDepthCamera], mediaType: .video, position: .unspecified) 103 104 public let photoOutput = AVCapturePhotoOutput() 105 106 public let movieOutput = AVCaptureMovieFileOutput() 107 108 var videoCaptureProcessor: VideoCaptureProcessor? 109 var photoCaptureProcessor: PhotoCaptureProcessor? 110 111 public var keyValueObservations = [NSKeyValueObservation]() 112 113 override public init() { 114 super.init() 115 116 DispatchQueue.main.async { 117 self.isCameraButtonDisabled = true 118 self.isCameraUnavailable = true 119 } 120 } 121 122 enum SessionSetupResult { 123 case success 124 case notAuthorized 125 case configurationFailed 126 } 127 128 public func configure() { 129 if !self.isSessionRunning && !self.isConfigured { 130 sessionQueue.async { 131 self.configureSession() 132 } 133 } 134 } 135 136 public func checkForPermissions() { 137 switch AVCaptureDevice.authorizationStatus(for: .video) { 138 case .authorized: 139 break 140 case .notDetermined: 141 sessionQueue.suspend() 142 AVCaptureDevice.requestAccess(for: .video, completionHandler: { granted in 143 if !granted { 144 self.setupResult = .notAuthorized 145 } 146 self.sessionQueue.resume() 147 }) 148 149 default: 150 setupResult = .notAuthorized 151 152 DispatchQueue.main.async { 153 self.alertError = AlertError(title: "Camera Access", message: "Damus needs camera and microphone access. Enable in settings.", primaryButtonTitle: "Go to settings", secondaryButtonTitle: nil, primaryAction: { 154 UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!, 155 options: [:], completionHandler: nil) 156 157 }, secondaryAction: nil) 158 self.shouldShowAlertView = true 159 self.isCameraUnavailable = true 160 self.isCameraButtonDisabled = true 161 } 162 } 163 } 164 165 private func configureSession() { 166 if setupResult != .success { 167 return 168 } 169 170 session.beginConfiguration() 171 172 session.sessionPreset = .high 173 174 // Add video input. 175 do { 176 var defaultVideoDevice: AVCaptureDevice? 177 178 if let backCameraDevice = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) { 179 // If a rear dual camera is not available, default to the rear wide angle camera. 180 defaultVideoDevice = backCameraDevice 181 } else if let frontCameraDevice = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .front) { 182 // If the rear wide angle camera isn't available, default to the front wide angle camera. 183 defaultVideoDevice = frontCameraDevice 184 } 185 186 guard let videoDevice = defaultVideoDevice else { 187 print("Default video device is unavailable.") 188 setupResult = .configurationFailed 189 session.commitConfiguration() 190 return 191 } 192 193 let videoDeviceInput = try AVCaptureDeviceInput(device: videoDevice) 194 195 if session.canAddInput(videoDeviceInput) { 196 session.addInput(videoDeviceInput) 197 self.videoDeviceInput = videoDeviceInput 198 } else { 199 print("Couldn't add video device input to the session.") 200 setupResult = .configurationFailed 201 session.commitConfiguration() 202 return 203 } 204 205 let audioDevice = AVCaptureDevice.default(for: .audio) 206 let audioDeviceInput = try AVCaptureDeviceInput(device: audioDevice!) 207 208 if session.canAddInput(audioDeviceInput) { 209 session.addInput(audioDeviceInput) 210 self.audioDeviceInput = audioDeviceInput 211 } else { 212 print("Couldn't add audio device input to the session.") 213 setupResult = .configurationFailed 214 session.commitConfiguration() 215 return 216 } 217 218 // Add video output 219 if session.canAddOutput(movieOutput) { 220 session.addOutput(movieOutput) 221 } else { 222 print("Could not add movie output to the session") 223 setupResult = .configurationFailed 224 session.commitConfiguration() 225 return 226 } 227 } catch { 228 print("Couldn't create video device input: \(error)") 229 setupResult = .configurationFailed 230 session.commitConfiguration() 231 return 232 } 233 234 // Add the photo output. 235 if session.canAddOutput(photoOutput) { 236 session.addOutput(photoOutput) 237 238 photoOutput.maxPhotoQualityPrioritization = .quality 239 240 } else { 241 print("Could not add photo output to the session") 242 setupResult = .configurationFailed 243 session.commitConfiguration() 244 return 245 } 246 247 session.commitConfiguration() 248 self.isConfigured = true 249 250 self.start() 251 } 252 253 private func resumeInterruptedSession() { 254 sessionQueue.async { 255 self.session.startRunning() 256 self.isSessionRunning = self.session.isRunning 257 if !self.session.isRunning { 258 DispatchQueue.main.async { 259 self.alertError = AlertError(title: "Camera Error", message: "Unable to resume camera", primaryButtonTitle: "Accept", secondaryButtonTitle: nil, primaryAction: nil, secondaryAction: nil) 260 self.shouldShowAlertView = true 261 self.isCameraUnavailable = true 262 self.isCameraButtonDisabled = true 263 } 264 } else { 265 DispatchQueue.main.async { 266 self.isCameraUnavailable = false 267 self.isCameraButtonDisabled = false 268 } 269 } 270 } 271 } 272 273 public func changeCamera() { 274 DispatchQueue.main.async { 275 self.isCameraButtonDisabled = true 276 } 277 278 sessionQueue.async { 279 let currentVideoDevice = self.videoDeviceInput.device 280 let currentPosition = currentVideoDevice.position 281 282 let preferredPosition: AVCaptureDevice.Position 283 let preferredDeviceType: AVCaptureDevice.DeviceType 284 285 switch currentPosition { 286 case .unspecified, .front: 287 preferredPosition = .back 288 preferredDeviceType = .builtInWideAngleCamera 289 290 case .back: 291 preferredPosition = .front 292 preferredDeviceType = .builtInWideAngleCamera 293 294 @unknown default: 295 print("Unknown capture position. Defaulting to back, dual-camera.") 296 preferredPosition = .back 297 preferredDeviceType = .builtInWideAngleCamera 298 } 299 let devices = self.videoDeviceDiscoverySession.devices 300 var newVideoDevice: AVCaptureDevice? = nil 301 302 if let device = devices.first(where: { $0.position == preferredPosition && $0.deviceType == preferredDeviceType }) { 303 newVideoDevice = device 304 } else if let device = devices.first(where: { $0.position == preferredPosition }) { 305 newVideoDevice = device 306 } 307 308 if let videoDevice = newVideoDevice { 309 do { 310 let videoDeviceInput = try AVCaptureDeviceInput(device: videoDevice) 311 312 self.session.beginConfiguration() 313 314 self.session.removeInput(self.videoDeviceInput) 315 316 if self.session.canAddInput(videoDeviceInput) { 317 NotificationCenter.default.removeObserver(self, name: .AVCaptureDeviceSubjectAreaDidChange, object: currentVideoDevice) 318 NotificationCenter.default.addObserver(self, selector: #selector(self.subjectAreaDidChange), name: .AVCaptureDeviceSubjectAreaDidChange, object: videoDeviceInput.device) 319 320 self.session.addInput(videoDeviceInput) 321 self.videoDeviceInput = videoDeviceInput 322 } else { 323 self.session.addInput(self.videoDeviceInput) 324 } 325 326 if let connection = self.photoOutput.connection(with: .video) { 327 if connection.isVideoStabilizationSupported { 328 connection.preferredVideoStabilizationMode = .auto 329 } 330 } 331 332 self.photoOutput.maxPhotoQualityPrioritization = .quality 333 334 self.session.commitConfiguration() 335 } catch { 336 print("Error occurred while creating video device input: \(error)") 337 } 338 } 339 340 DispatchQueue.main.async { 341 self.isCameraButtonDisabled = false 342 } 343 } 344 } 345 346 public func focus(with focusMode: AVCaptureDevice.FocusMode, exposureMode: AVCaptureDevice.ExposureMode, at devicePoint: CGPoint, monitorSubjectAreaChange: Bool) { 347 sessionQueue.async { 348 guard let device = self.videoDeviceInput?.device else { return } 349 do { 350 try device.lockForConfiguration() 351 352 if device.isFocusPointOfInterestSupported && device.isFocusModeSupported(focusMode) { 353 device.focusPointOfInterest = devicePoint 354 device.focusMode = focusMode 355 } 356 357 if device.isExposurePointOfInterestSupported && device.isExposureModeSupported(exposureMode) { 358 device.exposurePointOfInterest = devicePoint 359 device.exposureMode = exposureMode 360 } 361 362 device.isSubjectAreaChangeMonitoringEnabled = monitorSubjectAreaChange 363 device.unlockForConfiguration() 364 } catch { 365 print("Could not lock device for configuration: \(error)") 366 } 367 } 368 } 369 370 371 public func focus(at focusPoint: CGPoint) { 372 let device = self.videoDeviceInput.device 373 do { 374 try device.lockForConfiguration() 375 if device.isFocusPointOfInterestSupported { 376 device.focusPointOfInterest = focusPoint 377 device.exposurePointOfInterest = focusPoint 378 device.exposureMode = .continuousAutoExposure 379 device.focusMode = .continuousAutoFocus 380 device.unlockForConfiguration() 381 } 382 } 383 catch { 384 print(error.localizedDescription) 385 } 386 } 387 388 @objc public func stop(completion: (() -> ())? = nil) { 389 sessionQueue.async { 390 if self.isSessionRunning { 391 if self.setupResult == .success { 392 self.session.stopRunning() 393 self.isSessionRunning = self.session.isRunning 394 print("CAMERA STOPPED") 395 self.removeObservers() 396 397 if !self.session.isRunning { 398 DispatchQueue.main.async { 399 self.isCameraButtonDisabled = true 400 self.isCameraUnavailable = true 401 completion?() 402 } 403 } 404 } 405 } 406 } 407 } 408 409 @objc public func start() { 410 sessionQueue.async { 411 if !self.isSessionRunning && self.isConfigured { 412 switch self.setupResult { 413 case .success: 414 self.addObservers() 415 self.session.startRunning() 416 print("CAMERA RUNNING") 417 self.isSessionRunning = self.session.isRunning 418 419 if self.session.isRunning { 420 DispatchQueue.main.async { 421 self.isCameraButtonDisabled = false 422 self.isCameraUnavailable = false 423 } 424 } 425 426 case .notAuthorized: 427 print("Application not authorized to use camera") 428 DispatchQueue.main.async { 429 self.isCameraButtonDisabled = true 430 self.isCameraUnavailable = true 431 } 432 433 case .configurationFailed: 434 DispatchQueue.main.async { 435 self.alertError = AlertError(title: "Camera Error", message: "Camera configuration failed. Either your device camera is not available or other application is using it", primaryButtonTitle: "Accept", secondaryButtonTitle: nil, primaryAction: nil, secondaryAction: nil) 436 self.shouldShowAlertView = true 437 self.isCameraButtonDisabled = true 438 self.isCameraUnavailable = true 439 } 440 } 441 } 442 } 443 } 444 445 public func set(zoom: CGFloat) { 446 let factor = zoom < 1 ? 1 : zoom 447 let device = self.videoDeviceInput.device 448 449 do { 450 try device.lockForConfiguration() 451 device.videoZoomFactor = factor 452 device.unlockForConfiguration() 453 } 454 catch { 455 print(error.localizedDescription) 456 } 457 } 458 459 public func capturePhoto() { 460 if self.setupResult != .configurationFailed { 461 let videoPreviewLayerOrientation: AVCaptureVideoOrientation = .portrait 462 self.isCameraButtonDisabled = true 463 464 sessionQueue.async { 465 if let photoOutputConnection = self.photoOutput.connection(with: .video) { 466 photoOutputConnection.videoOrientation = videoPreviewLayerOrientation 467 } 468 var photoSettings = AVCapturePhotoSettings() 469 470 // Capture HEIF photos when supported. Enable according to user settings and high-resolution photos. 471 if (self.photoOutput.availablePhotoCodecTypes.contains(.hevc)) { 472 photoSettings = AVCapturePhotoSettings(format: [AVVideoCodecKey: AVVideoCodecType.hevc]) 473 } 474 475 if self.videoDeviceInput.device.isFlashAvailable { 476 photoSettings.flashMode = self.flashMode 477 } 478 479 if !photoSettings.__availablePreviewPhotoPixelFormatTypes.isEmpty { 480 photoSettings.previewPhotoFormat = [kCVPixelBufferPixelFormatTypeKey as String: photoSettings.__availablePreviewPhotoPixelFormatTypes.first!] 481 } 482 483 photoSettings.photoQualityPrioritization = .speed 484 485 if self.photoCaptureProcessor == nil { 486 self.photoCaptureProcessor = PhotoCaptureProcessor(with: photoSettings, photoOutput: self.photoOutput, willCapturePhotoAnimation: { 487 DispatchQueue.main.async { 488 self.willCapturePhoto.toggle() 489 self.willCapturePhoto.toggle() 490 } 491 }, completionHandler: { (photoCaptureProcessor) in 492 if let data = photoCaptureProcessor.photoData { 493 let url = self.savePhoto(data: data) 494 if let unwrappedURL = url { 495 self.thumbnail = Thumbnail(type: .image, url: unwrappedURL) 496 } 497 } else { 498 print("Data for photo not found") 499 } 500 501 self.isCameraButtonDisabled = false 502 }, photoProcessingHandler: { animate in 503 self.isPhotoProcessing = animate 504 }) 505 } 506 507 self.photoCaptureProcessor?.capturePhoto(settings: photoSettings) 508 } 509 } 510 } 511 512 public func startRecording() { 513 if self.setupResult != .configurationFailed { 514 let videoPreviewLayerOrientation: AVCaptureVideoOrientation = .portrait 515 self.isCameraButtonDisabled = true 516 517 sessionQueue.async { 518 if let videoOutputConnection = self.movieOutput.connection(with: .video) { 519 videoOutputConnection.videoOrientation = videoPreviewLayerOrientation 520 521 var videoSettings = [String: Any]() 522 523 if self.movieOutput.availableVideoCodecTypes.contains(.hevc) == true { 524 videoSettings[AVVideoCodecKey] = AVVideoCodecType.hevc 525 self.movieOutput.setOutputSettings(videoSettings, for: videoOutputConnection) 526 } 527 } 528 529 if self.videoCaptureProcessor == nil { 530 self.videoCaptureProcessor = VideoCaptureProcessor(movieOutput: self.movieOutput, beginHandler: { 531 self.isRecording = true 532 }, completionHandler: { (videoCaptureProcessor, outputFileURL) in 533 self.isCameraButtonDisabled = false 534 self.captureMode = .image 535 536 self.mediaItems.append(MediaItem(url: outputFileURL, type: .video)) 537 self.thumbnail = Thumbnail(type: .video, url: outputFileURL) 538 }, videoProcessingHandler: { animate in 539 self.isPhotoProcessing = animate 540 }) 541 } 542 543 self.videoCaptureProcessor?.startCapture(session: self.session) 544 } 545 } 546 } 547 548 func stopRecording() { 549 if let videoCaptureProcessor = self.videoCaptureProcessor { 550 isRecording = false 551 videoCaptureProcessor.stopCapture() 552 } 553 } 554 555 func savePhoto(imageType: String = "jpeg", data: Data) -> URL? { 556 guard let uiImage = UIImage(data: data) else { 557 print("Error converting media data to UIImage") 558 return nil 559 } 560 561 guard let compressedData = uiImage.jpegData(compressionQuality: 0.8) else { 562 print("Error converting UIImage to JPEG data") 563 return nil 564 } 565 566 let temporaryDirectory = NSTemporaryDirectory() 567 let tempFileName = "\(UUID().uuidString).\(imageType)" 568 let tempFileURL = URL(fileURLWithPath: temporaryDirectory).appendingPathComponent(tempFileName) 569 570 do { 571 try compressedData.write(to: tempFileURL) 572 self.mediaItems.append(MediaItem(url: tempFileURL, type: .image)) 573 return tempFileURL 574 } catch { 575 print("Error saving image data to temporary URL: \(error.localizedDescription)") 576 } 577 return nil 578 } 579 580 private func addObservers() { 581 let systemPressureStateObservation = observe(\.videoDeviceInput.device.systemPressureState, options: .new) { _, change in 582 guard let systemPressureState = change.newValue else { return } 583 self.setRecommendedFrameRateRangeForPressureState(systemPressureState: systemPressureState) 584 } 585 keyValueObservations.append(systemPressureStateObservation) 586 587 // NotificationCenter.default.addObserver(self, selector: #selector(self.onOrientationChange), name: UIDevice.orientationDidChangeNotification, object: nil) 588 589 NotificationCenter.default.addObserver(self, 590 selector: #selector(subjectAreaDidChange), 591 name: .AVCaptureDeviceSubjectAreaDidChange, 592 object: videoDeviceInput.device) 593 594 NotificationCenter.default.addObserver(self, selector: #selector(uiRequestedNewFocusArea), name: .init(rawValue: "UserDidRequestNewFocusPoint"), object: nil) 595 596 NotificationCenter.default.addObserver(self, 597 selector: #selector(sessionRuntimeError), 598 name: .AVCaptureSessionRuntimeError, 599 object: session) 600 601 NotificationCenter.default.addObserver(self, 602 selector: #selector(sessionWasInterrupted), 603 name: .AVCaptureSessionWasInterrupted, 604 object: session) 605 606 NotificationCenter.default.addObserver(self, 607 selector: #selector(sessionInterruptionEnded), 608 name: .AVCaptureSessionInterruptionEnded, 609 object: session) 610 } 611 612 private func removeObservers() { 613 NotificationCenter.default.removeObserver(self) 614 615 for keyValueObservation in keyValueObservations { 616 keyValueObservation.invalidate() 617 } 618 keyValueObservations.removeAll() 619 } 620 621 @objc private func uiRequestedNewFocusArea(notification: NSNotification) { 622 guard let userInfo = notification.userInfo as? [String: Any], let devicePoint = userInfo["devicePoint"] as? CGPoint else { return } 623 self.focus(at: devicePoint) 624 } 625 626 @objc 627 private func subjectAreaDidChange(notification: NSNotification) { 628 let devicePoint = CGPoint(x: 0.5, y: 0.5) 629 focus(with: .continuousAutoFocus, exposureMode: .continuousAutoExposure, at: devicePoint, monitorSubjectAreaChange: false) 630 } 631 632 @objc 633 private func sessionRuntimeError(notification: NSNotification) { 634 guard let error = notification.userInfo?[AVCaptureSessionErrorKey] as? AVError else { return } 635 636 print("Capture session runtime error: \(error)") 637 638 if error.code == .mediaServicesWereReset { 639 sessionQueue.async { 640 if self.isSessionRunning { 641 self.session.startRunning() 642 self.isSessionRunning = self.session.isRunning 643 } 644 } 645 } 646 } 647 648 private func setRecommendedFrameRateRangeForPressureState(systemPressureState: AVCaptureDevice.SystemPressureState) { 649 let pressureLevel = systemPressureState.level 650 if pressureLevel == .serious || pressureLevel == .critical { 651 do { 652 try self.videoDeviceInput.device.lockForConfiguration() 653 print("WARNING: Reached elevated system pressure level: \(pressureLevel). Throttling frame rate.") 654 self.videoDeviceInput.device.activeVideoMinFrameDuration = CMTime(value: 1, timescale: 20) 655 self.videoDeviceInput.device.activeVideoMaxFrameDuration = CMTime(value: 1, timescale: 15) 656 self.videoDeviceInput.device.unlockForConfiguration() 657 } catch { 658 print("Could not lock device for configuration: \(error)") 659 } 660 } else if pressureLevel == .shutdown { 661 print("Session stopped running due to shutdown system pressure level.") 662 } 663 } 664 665 @objc 666 private func sessionWasInterrupted(notification: NSNotification) { 667 DispatchQueue.main.async { 668 self.isCameraUnavailable = true 669 } 670 671 if let userInfoValue = notification.userInfo?[AVCaptureSessionInterruptionReasonKey] as AnyObject?, 672 let reasonIntegerValue = userInfoValue.integerValue, 673 let reason = AVCaptureSession.InterruptionReason(rawValue: reasonIntegerValue) { 674 print("Capture session was interrupted with reason \(reason)") 675 676 if reason == .audioDeviceInUseByAnotherClient || reason == .videoDeviceInUseByAnotherClient { 677 print("Session stopped running due to video devies in use by another client.") 678 } else if reason == .videoDeviceNotAvailableWithMultipleForegroundApps { 679 print("Session stopped running due to video devies is not available with multiple foreground apps.") 680 } else if reason == .videoDeviceNotAvailableDueToSystemPressure { 681 print("Session stopped running due to shutdown system pressure level.") 682 } 683 } 684 } 685 686 @objc 687 private func sessionInterruptionEnded(notification: NSNotification) { 688 print("Capture session interruption ended") 689 DispatchQueue.main.async { 690 self.isCameraUnavailable = false 691 } 692 } 693 }