@capgo/camera-preview 7.3.11 → 7.4.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CapgoCameraPreview.podspec +16 -13
  2. package/README.md +492 -73
  3. package/android/build.gradle +11 -0
  4. package/android/gradle/wrapper/gradle-wrapper.properties +1 -1
  5. package/android/src/main/AndroidManifest.xml +5 -3
  6. package/android/src/main/java/com/ahm/capacitor/camera/preview/CameraPreview.java +968 -505
  7. package/android/src/main/java/com/ahm/capacitor/camera/preview/CameraXView.java +3017 -0
  8. package/android/src/main/java/com/ahm/capacitor/camera/preview/GridOverlayView.java +119 -0
  9. package/android/src/main/java/com/ahm/capacitor/camera/preview/model/CameraDevice.java +63 -0
  10. package/android/src/main/java/com/ahm/capacitor/camera/preview/model/CameraLens.java +79 -0
  11. package/android/src/main/java/com/ahm/capacitor/camera/preview/model/CameraSessionConfiguration.java +167 -0
  12. package/android/src/main/java/com/ahm/capacitor/camera/preview/model/LensInfo.java +40 -0
  13. package/android/src/main/java/com/ahm/capacitor/camera/preview/model/ZoomFactors.java +35 -0
  14. package/dist/docs.json +1041 -161
  15. package/dist/esm/definitions.d.ts +484 -84
  16. package/dist/esm/definitions.js +10 -1
  17. package/dist/esm/definitions.js.map +1 -1
  18. package/dist/esm/web.d.ts +78 -3
  19. package/dist/esm/web.js +813 -68
  20. package/dist/esm/web.js.map +1 -1
  21. package/dist/plugin.cjs.js +819 -68
  22. package/dist/plugin.cjs.js.map +1 -1
  23. package/dist/plugin.js +819 -68
  24. package/dist/plugin.js.map +1 -1
  25. package/ios/Sources/CapgoCameraPreviewPlugin/CameraController.swift +1658 -0
  26. package/ios/Sources/CapgoCameraPreviewPlugin/GridOverlayView.swift +65 -0
  27. package/ios/Sources/CapgoCameraPreviewPlugin/Plugin.swift +1548 -0
  28. package/ios/Tests/CameraPreviewPluginTests/CameraPreviewPluginTests.swift +15 -0
  29. package/package.json +2 -2
  30. package/android/src/main/java/com/ahm/capacitor/camera/preview/CameraActivity.java +0 -1279
  31. package/android/src/main/java/com/ahm/capacitor/camera/preview/CustomSurfaceView.java +0 -29
  32. package/android/src/main/java/com/ahm/capacitor/camera/preview/CustomTextureView.java +0 -39
  33. package/android/src/main/java/com/ahm/capacitor/camera/preview/Preview.java +0 -461
  34. package/android/src/main/java/com/ahm/capacitor/camera/preview/TapGestureDetector.java +0 -24
  35. package/ios/Plugin/CameraController.swift +0 -809
  36. package/ios/Plugin/Info.plist +0 -24
  37. package/ios/Plugin/Plugin.h +0 -10
  38. package/ios/Plugin/Plugin.m +0 -18
  39. package/ios/Plugin/Plugin.swift +0 -511
  40. package/ios/Plugin.xcodeproj/project.pbxproj +0 -593
  41. package/ios/Plugin.xcodeproj/project.xcworkspace/contents.xcworkspacedata +0 -7
  42. package/ios/Plugin.xcworkspace/contents.xcworkspacedata +0 -10
  43. package/ios/Plugin.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +0 -8
  44. package/ios/PluginTests/Info.plist +0 -22
  45. package/ios/PluginTests/PluginTests.swift +0 -83
  46. package/ios/Podfile +0 -13
  47. package/ios/Podfile.lock +0 -23
@@ -0,0 +1,1658 @@
1
+ import AVFoundation
2
+ import UIKit
3
+ import CoreLocation
4
+
5
+ class CameraController: NSObject {
6
+ var captureSession: AVCaptureSession?
7
+
8
+ var currentCameraPosition: CameraPosition?
9
+
10
+ var frontCamera: AVCaptureDevice?
11
+ var frontCameraInput: AVCaptureDeviceInput?
12
+
13
+ var dataOutput: AVCaptureVideoDataOutput?
14
+ var photoOutput: AVCapturePhotoOutput?
15
+
16
+ var rearCamera: AVCaptureDevice?
17
+ var rearCameraInput: AVCaptureDeviceInput?
18
+
19
+ var allDiscoveredDevices: [AVCaptureDevice] = []
20
+
21
+ var fileVideoOutput: AVCaptureMovieFileOutput?
22
+
23
+ var previewLayer: AVCaptureVideoPreviewLayer?
24
+ var gridOverlayView: GridOverlayView?
25
+ var focusIndicatorView: UIView?
26
+
27
+ var flashMode = AVCaptureDevice.FlashMode.off
28
+ var photoCaptureCompletionBlock: ((UIImage?, Error?) -> Void)?
29
+
30
+ var sampleBufferCaptureCompletionBlock: ((UIImage?, Error?) -> Void)?
31
+
32
+ // Add callback for detecting when first frame is ready
33
+ var firstFrameReadyCallback: (() -> Void)?
34
+ var hasReceivedFirstFrame = false
35
+
36
+ var audioDevice: AVCaptureDevice?
37
+ var audioInput: AVCaptureDeviceInput?
38
+
39
+ var zoomFactor: CGFloat = 2.0
40
+ private var lastZoomUpdateTime: TimeInterval = 0
41
+ private let zoomUpdateThrottle: TimeInterval = 1.0 / 60.0 // 60 FPS max
42
+
43
+ var videoFileURL: URL?
44
+ private let saneMaxZoomFactor: CGFloat = 25.5
45
+
46
+ // Track output preparation status
47
+ private var outputsPrepared: Bool = false
48
+
49
+ var isUsingMultiLensVirtualCamera: Bool {
50
+ guard let device = (currentCameraPosition == .rear) ? rearCamera : frontCamera else { return false }
51
+ // A rear multi-lens virtual camera will have a min zoom of 1.0 but support wider angles
52
+ return device.position == .back && device.isVirtualDevice && device.constituentDevices.count > 1
53
+ }
54
+ }
55
+
56
+ extension CameraController {
57
+ func prepareFullSession() {
58
+ // This function is now deprecated in favor of inline session creation in prepare()
59
+ // Kept for backward compatibility
60
+ guard self.captureSession == nil else { return }
61
+
62
+ self.captureSession = AVCaptureSession()
63
+ }
64
+
65
+ private func ensureCamerasDiscovered() {
66
+ // Rediscover cameras if the array is empty OR if the camera pointers are nil
67
+ guard allDiscoveredDevices.isEmpty || (rearCamera == nil && frontCamera == nil) else { return }
68
+ discoverAndConfigureCameras()
69
+ }
70
+
71
+ private func discoverAndConfigureCameras() {
72
+ let deviceTypes: [AVCaptureDevice.DeviceType] = [
73
+ .builtInWideAngleCamera,
74
+ .builtInUltraWideCamera,
75
+ .builtInTelephotoCamera,
76
+ .builtInDualCamera,
77
+ .builtInDualWideCamera,
78
+ .builtInTripleCamera,
79
+ .builtInTrueDepthCamera
80
+ ]
81
+
82
+ let session = AVCaptureDevice.DiscoverySession(deviceTypes: deviceTypes, mediaType: AVMediaType.video, position: .unspecified)
83
+ let cameras = session.devices.compactMap { $0 }
84
+
85
+ // Store all discovered devices for fast lookup later
86
+ self.allDiscoveredDevices = cameras
87
+
88
+ // Log all found devices for debugging
89
+
90
+ for camera in cameras {
91
+ let constituentCount = camera.isVirtualDevice ? camera.constituentDevices.count : 1
92
+
93
+ }
94
+
95
+ // Set front camera (usually just one option)
96
+ self.frontCamera = cameras.first(where: { $0.position == .front })
97
+
98
+ // Find rear camera - prefer tripleCamera for multi-lens support
99
+ let rearCameras = cameras.filter { $0.position == .back }
100
+
101
+ // First try to find built-in triple camera (provides access to all lenses)
102
+ if let tripleCamera = rearCameras.first(where: {
103
+ $0.deviceType == .builtInTripleCamera
104
+ }) {
105
+ self.rearCamera = tripleCamera
106
+ } else if let dualWideCamera = rearCameras.first(where: {
107
+ $0.deviceType == .builtInDualWideCamera
108
+ }) {
109
+ // Fallback to dual wide camera
110
+ self.rearCamera = dualWideCamera
111
+ } else if let dualCamera = rearCameras.first(where: {
112
+ $0.deviceType == .builtInDualCamera
113
+ }) {
114
+ // Fallback to dual camera
115
+ self.rearCamera = dualCamera
116
+ } else if let wideAngleCamera = rearCameras.first(where: {
117
+ $0.deviceType == .builtInWideAngleCamera
118
+ }) {
119
+ // Fallback to wide angle camera
120
+ self.rearCamera = wideAngleCamera
121
+ } else if let firstRearCamera = rearCameras.first {
122
+ // Final fallback to any rear camera
123
+ self.rearCamera = firstRearCamera
124
+ }
125
+
126
+ // Pre-configure focus modes
127
+ configureCameraFocus(camera: self.rearCamera)
128
+ configureCameraFocus(camera: self.frontCamera)
129
+ }
130
+
131
+ private func configureCameraFocus(camera: AVCaptureDevice?) {
132
+ guard let camera = camera else { return }
133
+
134
+ do {
135
+ try camera.lockForConfiguration()
136
+ if camera.isFocusModeSupported(.continuousAutoFocus) {
137
+ camera.focusMode = .continuousAutoFocus
138
+ }
139
+ camera.unlockForConfiguration()
140
+ } catch {
141
+ print("[CameraPreview] Could not configure focus for \(camera.localizedName): \(error)")
142
+ }
143
+ }
144
+
145
+ private func prepareOutputs() {
146
+ // Skip if already prepared
147
+ guard !self.outputsPrepared else { return }
148
+
149
+ // Create photo output
150
+ self.photoOutput = AVCapturePhotoOutput()
151
+ self.photoOutput?.isHighResolutionCaptureEnabled = true
152
+
153
+ // Create video output
154
+ self.fileVideoOutput = AVCaptureMovieFileOutput()
155
+
156
+ // Create data output for preview
157
+ self.dataOutput = AVCaptureVideoDataOutput()
158
+ self.dataOutput?.videoSettings = [
159
+ (kCVPixelBufferPixelFormatTypeKey as String): NSNumber(value: kCVPixelFormatType_32BGRA as UInt32)
160
+ ]
161
+ self.dataOutput?.alwaysDiscardsLateVideoFrames = true
162
+
163
+ // Pre-create preview layer to avoid delay later
164
+ if self.previewLayer == nil {
165
+ self.previewLayer = AVCaptureVideoPreviewLayer()
166
+ }
167
+
168
+ // Mark as prepared
169
+ self.outputsPrepared = true
170
+ }
171
+
172
+ func prepare(cameraPosition: String, deviceId: String? = nil, disableAudio: Bool, cameraMode: Bool, aspectRatio: String? = nil, initialZoomLevel: Float = 1.0, completionHandler: @escaping (Error?) -> Void) {
173
+ print("[CameraPreview] 🎬 Starting prepare - position: \(cameraPosition), deviceId: \(deviceId ?? "nil"), disableAudio: \(disableAudio), cameraMode: \(cameraMode), aspectRatio: \(aspectRatio ?? "nil"), zoom: \(initialZoomLevel)")
174
+
175
+ DispatchQueue.global(qos: .userInitiated).async { [weak self] in
176
+ guard let self = self else {
177
+ DispatchQueue.main.async {
178
+ completionHandler(CameraControllerError.unknown)
179
+ }
180
+ return
181
+ }
182
+
183
+ do {
184
+ // Create session if needed
185
+ if self.captureSession == nil {
186
+ self.captureSession = AVCaptureSession()
187
+ }
188
+
189
+ guard let captureSession = self.captureSession else {
190
+ throw CameraControllerError.captureSessionIsMissing
191
+ }
192
+
193
+ // Prepare outputs
194
+ self.prepareOutputs()
195
+
196
+ // Configure the session
197
+ captureSession.beginConfiguration()
198
+
199
+ // Set aspect ratio preset
200
+ self.configureSessionPreset(for: aspectRatio)
201
+
202
+ // Configure device inputs
203
+ try self.configureDeviceInputs(cameraPosition: cameraPosition, deviceId: deviceId, disableAudio: disableAudio)
204
+
205
+ // Add data output BEFORE starting session for faster first frame
206
+ if let dataOutput = self.dataOutput, captureSession.canAddOutput(dataOutput) {
207
+ captureSession.addOutput(dataOutput)
208
+ dataOutput.setSampleBufferDelegate(self, queue: DispatchQueue.main)
209
+ }
210
+
211
+ captureSession.commitConfiguration()
212
+
213
+ // Set initial zoom
214
+ self.setInitialZoom(level: initialZoomLevel)
215
+
216
+ // Start the session
217
+ captureSession.startRunning()
218
+
219
+ // Defer adding photo/video outputs to avoid blocking
220
+ // These aren't needed immediately for preview
221
+ DispatchQueue.global(qos: .utility).async { [weak self] in
222
+ guard let self = self else { return }
223
+
224
+ captureSession.beginConfiguration()
225
+
226
+ // Add photo output
227
+ if let photoOutput = self.photoOutput, captureSession.canAddOutput(photoOutput) {
228
+ photoOutput.isHighResolutionCaptureEnabled = true
229
+ captureSession.addOutput(photoOutput)
230
+ }
231
+
232
+ // Add video output if needed
233
+ if cameraMode, let fileVideoOutput = self.fileVideoOutput, captureSession.canAddOutput(fileVideoOutput) {
234
+ captureSession.addOutput(fileVideoOutput)
235
+ }
236
+
237
+ captureSession.commitConfiguration()
238
+ }
239
+
240
+ // Success callback
241
+ DispatchQueue.main.async {
242
+ completionHandler(nil)
243
+ }
244
+ } catch {
245
+ DispatchQueue.main.async {
246
+ completionHandler(error)
247
+ }
248
+ }
249
+ }
250
+ }
251
+
252
+ private func configureSessionPreset(for aspectRatio: String?) {
253
+ guard let captureSession = self.captureSession else { return }
254
+
255
+ var targetPreset: AVCaptureSession.Preset = .high
256
+
257
+ if let aspectRatio = aspectRatio {
258
+ switch aspectRatio {
259
+ case "16:9":
260
+ targetPreset = captureSession.canSetSessionPreset(.hd1920x1080) ? .hd1920x1080 : .high
261
+ case "4:3":
262
+ targetPreset = captureSession.canSetSessionPreset(.photo) ? .photo : .high
263
+ default:
264
+ targetPreset = .high
265
+ }
266
+ }
267
+
268
+ if captureSession.canSetSessionPreset(targetPreset) {
269
+ captureSession.sessionPreset = targetPreset
270
+ }
271
+ }
272
+
273
+ private func setInitialZoom(level: Float) {
274
+ let device = (currentCameraPosition == .rear) ? rearCamera : frontCamera
275
+ guard let device = device else {
276
+ print("[CameraPreview] No device available for initial zoom")
277
+ return
278
+ }
279
+
280
+ let minZoom = device.minAvailableVideoZoomFactor
281
+ let maxZoom = min(device.maxAvailableVideoZoomFactor, saneMaxZoomFactor)
282
+
283
+ let adjustedLevel = level
284
+
285
+ guard CGFloat(adjustedLevel) >= minZoom && CGFloat(adjustedLevel) <= maxZoom else {
286
+ print("[CameraPreview] Initial zoom level \(adjustedLevel) out of range (\(minZoom)-\(maxZoom))")
287
+ return
288
+ }
289
+
290
+ do {
291
+ try device.lockForConfiguration()
292
+ device.videoZoomFactor = CGFloat(adjustedLevel)
293
+ device.unlockForConfiguration()
294
+ self.zoomFactor = CGFloat(adjustedLevel)
295
+ } catch {
296
+ print("[CameraPreview] Failed to set initial zoom: \(error)")
297
+ }
298
+ }
299
+
300
+ private func configureDeviceInputs(cameraPosition: String, deviceId: String?, disableAudio: Bool) throws {
301
+ guard let captureSession = self.captureSession else { throw CameraControllerError.captureSessionIsMissing }
302
+
303
+ // Ensure cameras are discovered before configuring inputs
304
+ ensureCamerasDiscovered()
305
+
306
+ var selectedDevice: AVCaptureDevice?
307
+
308
+ // If deviceId is specified, find that specific device from discovered devices
309
+ if let deviceId = deviceId {
310
+ selectedDevice = self.allDiscoveredDevices.first(where: { $0.uniqueID == deviceId })
311
+ guard selectedDevice != nil else {
312
+ throw CameraControllerError.noCamerasAvailable
313
+ }
314
+ } else {
315
+ // Use position-based selection from discovered cameras
316
+ if cameraPosition == "rear" {
317
+ selectedDevice = self.rearCamera
318
+ } else if cameraPosition == "front" {
319
+ selectedDevice = self.frontCamera
320
+ }
321
+ }
322
+
323
+ guard let finalDevice = selectedDevice else {
324
+ throw CameraControllerError.noCamerasAvailable
325
+ }
326
+
327
+ let deviceInput = try AVCaptureDeviceInput(device: finalDevice)
328
+
329
+ if captureSession.canAddInput(deviceInput) {
330
+ captureSession.addInput(deviceInput)
331
+
332
+ if finalDevice.position == .front {
333
+ self.frontCameraInput = deviceInput
334
+ self.currentCameraPosition = .front
335
+ } else {
336
+ self.rearCameraInput = deviceInput
337
+ self.currentCameraPosition = .rear
338
+ }
339
+ } else {
340
+ throw CameraControllerError.inputsAreInvalid
341
+ }
342
+
343
+ // Add audio input if needed
344
+ if !disableAudio {
345
+ if self.audioDevice == nil {
346
+ self.audioDevice = AVCaptureDevice.default(for: AVMediaType.audio)
347
+ }
348
+ if let audioDevice = self.audioDevice {
349
+ self.audioInput = try AVCaptureDeviceInput(device: audioDevice)
350
+ if captureSession.canAddInput(self.audioInput!) {
351
+ captureSession.addInput(self.audioInput!)
352
+ } else {
353
+ throw CameraControllerError.inputsAreInvalid
354
+ }
355
+ }
356
+ }
357
+ }
358
+
359
+ func displayPreview(on view: UIView) throws {
360
+ guard let captureSession = self.captureSession, captureSession.isRunning else {
361
+ throw CameraControllerError.captureSessionIsMissing
362
+ }
363
+
364
+ // Create or reuse preview layer
365
+ let previewLayer: AVCaptureVideoPreviewLayer
366
+ if let existingLayer = self.previewLayer {
367
+ // Always reuse if we have one - just update the session if needed
368
+ previewLayer = existingLayer
369
+ if existingLayer.session != captureSession {
370
+ existingLayer.session = captureSession
371
+ }
372
+ } else {
373
+ // Create layer with minimal properties to speed up creation
374
+ previewLayer = AVCaptureVideoPreviewLayer()
375
+ previewLayer.session = captureSession
376
+ }
377
+
378
+ // Fast configuration without CATransaction overhead
379
+ previewLayer.videoGravity = .resizeAspectFill
380
+ previewLayer.frame = view.bounds
381
+
382
+ // Insert layer immediately (only if new)
383
+ if previewLayer.superlayer != view.layer {
384
+ view.layer.insertSublayer(previewLayer, at: 0)
385
+ }
386
+ self.previewLayer = previewLayer
387
+ }
388
+
389
+ func addGridOverlay(to view: UIView, gridMode: String) {
390
+ removeGridOverlay()
391
+
392
+ // Disable animation for grid overlay creation and positioning
393
+ CATransaction.begin()
394
+ CATransaction.setDisableActions(true)
395
+ gridOverlayView = GridOverlayView(frame: view.bounds)
396
+ gridOverlayView?.gridMode = gridMode
397
+ view.addSubview(gridOverlayView!)
398
+ CATransaction.commit()
399
+ }
400
+
401
+ func removeGridOverlay() {
402
+ gridOverlayView?.removeFromSuperview()
403
+ gridOverlayView = nil
404
+ }
405
+
406
+ func setupGestures(target: UIView, enableZoom: Bool) {
407
+ setupTapGesture(target: target, selector: #selector(handleTap(_:)), delegate: self)
408
+ if enableZoom {
409
+ setupPinchGesture(target: target, selector: #selector(handlePinch(_:)), delegate: self)
410
+ }
411
+ }
412
+
413
+ func setupTapGesture(target: UIView, selector: Selector, delegate: UIGestureRecognizerDelegate?) {
414
+ let tapGesture = UITapGestureRecognizer(target: self, action: selector)
415
+ tapGesture.delegate = delegate
416
+ target.addGestureRecognizer(tapGesture)
417
+ }
418
+
419
+ func setupPinchGesture(target: UIView, selector: Selector, delegate: UIGestureRecognizerDelegate?) {
420
+ let pinchGesture = UIPinchGestureRecognizer(target: self, action: selector)
421
+ pinchGesture.delegate = delegate
422
+ // Optimize gesture recognition for better performance
423
+ pinchGesture.delaysTouchesBegan = false
424
+ pinchGesture.delaysTouchesEnded = false
425
+ pinchGesture.cancelsTouchesInView = false
426
+ target.addGestureRecognizer(pinchGesture)
427
+ }
428
+
429
+ func updateVideoOrientation() {
430
+ if Thread.isMainThread {
431
+ updateVideoOrientationOnMainThread()
432
+ } else {
433
+ DispatchQueue.main.sync {
434
+ self.updateVideoOrientationOnMainThread()
435
+ }
436
+ }
437
+ }
438
+
439
+ private func updateVideoOrientationOnMainThread() {
440
+ let videoOrientation: AVCaptureVideoOrientation
441
+
442
+ // Use window scene interface orientation
443
+ if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene {
444
+ switch windowScene.interfaceOrientation {
445
+ case .portrait:
446
+ videoOrientation = .portrait
447
+ case .landscapeLeft:
448
+ videoOrientation = .landscapeLeft
449
+ case .landscapeRight:
450
+ videoOrientation = .landscapeRight
451
+ case .portraitUpsideDown:
452
+ videoOrientation = .portraitUpsideDown
453
+ case .unknown:
454
+ fallthrough
455
+ @unknown default:
456
+ videoOrientation = .portrait
457
+ }
458
+ } else {
459
+ videoOrientation = .portrait
460
+ }
461
+
462
+ previewLayer?.connection?.videoOrientation = videoOrientation
463
+ dataOutput?.connections.forEach { $0.videoOrientation = videoOrientation }
464
+ photoOutput?.connections.forEach { $0.videoOrientation = videoOrientation }
465
+ }
466
+
467
+ func switchCameras() throws {
468
+ guard let currentCameraPosition = currentCameraPosition,
469
+ let captureSession = self.captureSession else {
470
+ throw CameraControllerError.captureSessionIsMissing
471
+ }
472
+
473
+ // Ensure we have the necessary cameras
474
+ guard (currentCameraPosition == .front && rearCamera != nil) ||
475
+ (currentCameraPosition == .rear && frontCamera != nil) else {
476
+ throw CameraControllerError.noCamerasAvailable
477
+ }
478
+
479
+ // Store the current running state
480
+ let wasRunning = captureSession.isRunning
481
+ if wasRunning {
482
+ captureSession.stopRunning()
483
+ }
484
+
485
+ // Begin configuration
486
+ captureSession.beginConfiguration()
487
+ defer {
488
+ captureSession.commitConfiguration()
489
+ // Restart the session if it was running before
490
+ if wasRunning {
491
+ captureSession.startRunning()
492
+ }
493
+ }
494
+
495
+ // Store audio input if it exists
496
+ let audioInput = captureSession.inputs.first { ($0 as? AVCaptureDeviceInput)?.device.hasMediaType(.audio) ?? false }
497
+
498
+ // Remove only video inputs
499
+ captureSession.inputs.forEach { input in
500
+ if (input as? AVCaptureDeviceInput)?.device.hasMediaType(.video) ?? false {
501
+ captureSession.removeInput(input)
502
+ }
503
+ }
504
+
505
+ // Configure new camera
506
+ switch currentCameraPosition {
507
+ case .front:
508
+ guard let rearCamera = rearCamera else {
509
+ throw CameraControllerError.invalidOperation
510
+ }
511
+
512
+ // Configure rear camera
513
+ try rearCamera.lockForConfiguration()
514
+ if rearCamera.isFocusModeSupported(.continuousAutoFocus) {
515
+ rearCamera.focusMode = .continuousAutoFocus
516
+ }
517
+ rearCamera.unlockForConfiguration()
518
+
519
+ if let newInput = try? AVCaptureDeviceInput(device: rearCamera),
520
+ captureSession.canAddInput(newInput) {
521
+ captureSession.addInput(newInput)
522
+ rearCameraInput = newInput
523
+ self.currentCameraPosition = .rear
524
+ } else {
525
+ throw CameraControllerError.invalidOperation
526
+ }
527
+ case .rear:
528
+ guard let frontCamera = frontCamera else {
529
+ throw CameraControllerError.invalidOperation
530
+ }
531
+
532
+ // Configure front camera
533
+ try frontCamera.lockForConfiguration()
534
+ if frontCamera.isFocusModeSupported(.continuousAutoFocus) {
535
+ frontCamera.focusMode = .continuousAutoFocus
536
+ }
537
+ frontCamera.unlockForConfiguration()
538
+
539
+ if let newInput = try? AVCaptureDeviceInput(device: frontCamera),
540
+ captureSession.canAddInput(newInput) {
541
+ captureSession.addInput(newInput)
542
+ frontCameraInput = newInput
543
+ self.currentCameraPosition = .front
544
+ } else {
545
+ throw CameraControllerError.invalidOperation
546
+ }
547
+ }
548
+
549
+ // Re-add audio input if it existed
550
+ if let audioInput = audioInput, captureSession.canAddInput(audioInput) {
551
+ captureSession.addInput(audioInput)
552
+ }
553
+
554
+ // Update video orientation
555
+ self.updateVideoOrientation()
556
+ }
557
+
558
+ func captureImage(width: Int?, height: Int?, aspectRatio: String?, quality: Float, gpsLocation: CLLocation?, completion: @escaping (UIImage?, Error?) -> Void) {
559
+ print("[CameraPreview] captureImage called - width: \(width ?? -1), height: \(height ?? -1), aspectRatio: \(aspectRatio ?? "nil")")
560
+
561
+ guard let photoOutput = self.photoOutput else {
562
+ completion(nil, NSError(domain: "Camera", code: 0, userInfo: [NSLocalizedDescriptionKey: "Photo output is not available"]))
563
+ return
564
+ }
565
+
566
+ let settings = AVCapturePhotoSettings()
567
+
568
+ // Apply the current flash mode to the photo settings
569
+ // Check if the current device supports flash
570
+ var currentCamera: AVCaptureDevice?
571
+ switch currentCameraPosition {
572
+ case .front:
573
+ currentCamera = self.frontCamera
574
+ case .rear:
575
+ currentCamera = self.rearCamera
576
+ default:
577
+ break
578
+ }
579
+
580
+ // Only apply flash if the device has flash and the flash mode is supported
581
+ if let device = currentCamera, device.hasFlash {
582
+ let supportedFlashModes = photoOutput.supportedFlashModes
583
+ if supportedFlashModes.contains(self.flashMode) {
584
+ settings.flashMode = self.flashMode
585
+ }
586
+ }
587
+
588
+ self.photoCaptureCompletionBlock = { (image, error) in
589
+ if let error = error {
590
+ completion(nil, error)
591
+ return
592
+ }
593
+
594
+ guard let image = image else {
595
+ completion(nil, NSError(domain: "Camera", code: 0, userInfo: [NSLocalizedDescriptionKey: "Failed to capture image"]))
596
+ return
597
+ }
598
+
599
+ if let location = gpsLocation {
600
+ self.addGPSMetadata(to: image, location: location)
601
+ }
602
+
603
+ var finalImage = image
604
+
605
+ // Determine what to do based on parameters
606
+ if let width = width, let height = height {
607
+ // Specific dimensions requested - resize to exact size
608
+ finalImage = self.resizeImage(image: image, to: CGSize(width: width, height: height))!
609
+ print("[CameraPreview] Resized to exact dimensions: \(finalImage.size.width)x\(finalImage.size.height)")
610
+ } else if let aspectRatio = aspectRatio {
611
+ // Aspect ratio specified - crop to that ratio
612
+ let components = aspectRatio.split(separator: ":").compactMap { Double($0) }
613
+ if components.count == 2 {
614
+ // For capture in portrait orientation, swap the aspect ratio (16:9 becomes 9:16)
615
+ let isPortrait = image.size.height > image.size.width
616
+ let targetAspectRatio = isPortrait ? components[1] / components[0] : components[0] / components[1]
617
+ let imageSize = image.size
618
+ let originalAspectRatio = imageSize.width / imageSize.height
619
+
620
+ // Only crop if the aspect ratios don't match
621
+ if abs(originalAspectRatio - targetAspectRatio) > 0.01 {
622
+ var targetSize = imageSize
623
+
624
+ if originalAspectRatio > targetAspectRatio {
625
+ // Original is wider than target - fit by height
626
+ targetSize.width = imageSize.height * CGFloat(targetAspectRatio)
627
+ } else {
628
+ // Original is taller than target - fit by width
629
+ targetSize.height = imageSize.width / CGFloat(targetAspectRatio)
630
+ }
631
+
632
+ // Center crop the image
633
+ if let croppedImage = self.cropImageToAspectRatio(image: image, targetSize: targetSize) {
634
+ finalImage = croppedImage
635
+ print("[CameraPreview] Applied aspect ratio crop: \(finalImage.size.width)x\(finalImage.size.height)")
636
+ }
637
+ }
638
+ }
639
+ } else {
640
+ // No parameters specified - crop to match what's visible in the preview
641
+ // This ensures we capture exactly what the user sees
642
+ if let previewLayer = self.previewLayer,
643
+ let previewCroppedImage = self.cropImageToMatchPreview(image: image, previewLayer: previewLayer) {
644
+ finalImage = previewCroppedImage
645
+ print("[CameraPreview] Cropped to match preview: \(finalImage.size.width)x\(finalImage.size.height)")
646
+ }
647
+ }
648
+
649
+ completion(finalImage, nil)
650
+ }
651
+
652
+ photoOutput.capturePhoto(with: settings, delegate: self)
653
+ }
654
+
655
+ func addGPSMetadata(to image: UIImage, location: CLLocation) {
656
+ guard let jpegData = image.jpegData(compressionQuality: 1.0),
657
+ let source = CGImageSourceCreateWithData(jpegData as CFData, nil),
658
+ let uti = CGImageSourceGetType(source) else { return }
659
+
660
+ var metadata = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [String: Any] ?? [:]
661
+
662
+ let formatter = DateFormatter()
663
+ formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ"
664
+ formatter.timeZone = TimeZone(abbreviation: "UTC")
665
+
666
+ let gpsDict: [String: Any] = [
667
+ kCGImagePropertyGPSLatitude as String: abs(location.coordinate.latitude),
668
+ kCGImagePropertyGPSLatitudeRef as String: location.coordinate.latitude >= 0 ? "N" : "S",
669
+ kCGImagePropertyGPSLongitude as String: abs(location.coordinate.longitude),
670
+ kCGImagePropertyGPSLongitudeRef as String: location.coordinate.longitude >= 0 ? "E" : "W",
671
+ kCGImagePropertyGPSTimeStamp as String: formatter.string(from: location.timestamp),
672
+ kCGImagePropertyGPSAltitude as String: location.altitude,
673
+ kCGImagePropertyGPSAltitudeRef as String: location.altitude >= 0 ? 0 : 1
674
+ ]
675
+
676
+ metadata[kCGImagePropertyGPSDictionary as String] = gpsDict
677
+
678
+ let destData = NSMutableData()
679
+ guard let destination = CGImageDestinationCreateWithData(destData, uti, 1, nil) else { return }
680
+ CGImageDestinationAddImageFromSource(destination, source, 0, metadata as CFDictionary)
681
+ CGImageDestinationFinalize(destination)
682
+ }
683
+
684
+ func resizeImage(image: UIImage, to size: CGSize) -> UIImage? {
685
+ let renderer = UIGraphicsImageRenderer(size: size)
686
+ let resizedImage = renderer.image { (_) in
687
+ image.draw(in: CGRect(origin: .zero, size: size))
688
+ }
689
+ return resizedImage
690
+ }
691
+
692
+ func cropImageToAspectRatio(image: UIImage, targetSize: CGSize) -> UIImage? {
693
+ let imageSize = image.size
694
+
695
+ // Calculate the crop rect - center crop
696
+ let xOffset = (imageSize.width - targetSize.width) / 2
697
+ let yOffset = (imageSize.height - targetSize.height) / 2
698
+ let cropRect = CGRect(x: xOffset, y: yOffset, width: targetSize.width, height: targetSize.height)
699
+
700
+ // Create the cropped image
701
+ guard let cgImage = image.cgImage,
702
+ let croppedCGImage = cgImage.cropping(to: cropRect) else {
703
+ return nil
704
+ }
705
+
706
+ return UIImage(cgImage: croppedCGImage, scale: image.scale, orientation: image.imageOrientation)
707
+ }
708
+
709
+ func cropImageToMatchPreview(image: UIImage, previewLayer: AVCaptureVideoPreviewLayer) -> UIImage? {
710
+ // When using resizeAspectFill, the preview layer shows a cropped portion of the video
711
+ // We need to calculate what portion of the captured image corresponds to what's visible
712
+
713
+ let previewBounds = previewLayer.bounds
714
+ let previewAspectRatio = previewBounds.width / previewBounds.height
715
+
716
+ // Get the dimensions of the captured image
717
+ let imageSize = image.size
718
+ let imageAspectRatio = imageSize.width / imageSize.height
719
+
720
+ print("[CameraPreview] cropImageToMatchPreview - Preview bounds: \(previewBounds.width)x\(previewBounds.height) (ratio: \(previewAspectRatio))")
721
+ print("[CameraPreview] cropImageToMatchPreview - Image size: \(imageSize.width)x\(imageSize.height) (ratio: \(imageAspectRatio))")
722
+
723
+ // Since we're using resizeAspectFill, we need to calculate what portion of the image
724
+ // is visible in the preview
725
+ var cropRect: CGRect
726
+
727
+ if imageAspectRatio > previewAspectRatio {
728
+ // Image is wider than preview - crop horizontally
729
+ let visibleWidth = imageSize.height * previewAspectRatio
730
+ let xOffset = (imageSize.width - visibleWidth) / 2
731
+ cropRect = CGRect(x: xOffset, y: 0, width: visibleWidth, height: imageSize.height)
732
+
733
+ } else {
734
+ // Image is taller than preview - crop vertically
735
+ let visibleHeight = imageSize.width / previewAspectRatio
736
+ let yOffset = (imageSize.height - visibleHeight) / 2
737
+ cropRect = CGRect(x: 0, y: yOffset, width: imageSize.width, height: visibleHeight)
738
+
739
+ }
740
+
741
+ // Create the cropped image
742
+ guard let cgImage = image.cgImage,
743
+ let croppedCGImage = cgImage.cropping(to: cropRect) else {
744
+
745
+ return nil
746
+ }
747
+
748
+ let result = UIImage(cgImage: croppedCGImage, scale: image.scale, orientation: image.imageOrientation)
749
+
750
+ return result
751
+ }
752
+
753
+ func captureSample(completion: @escaping (UIImage?, Error?) -> Void) {
754
+ guard let captureSession = captureSession,
755
+ captureSession.isRunning else {
756
+ completion(nil, CameraControllerError.captureSessionIsMissing)
757
+ return
758
+ }
759
+
760
+ self.sampleBufferCaptureCompletionBlock = completion
761
+ }
762
+
763
+ func getSupportedFlashModes() throws -> [String] {
764
+ var currentCamera: AVCaptureDevice?
765
+ switch currentCameraPosition {
766
+ case .front:
767
+ currentCamera = self.frontCamera!
768
+ case .rear:
769
+ currentCamera = self.rearCamera!
770
+ default: break
771
+ }
772
+
773
+ guard
774
+ let device = currentCamera
775
+ else {
776
+ throw CameraControllerError.noCamerasAvailable
777
+ }
778
+
779
+ var supportedFlashModesAsStrings: [String] = []
780
+ if device.hasFlash {
781
+ guard let supportedFlashModes: [AVCaptureDevice.FlashMode] = self.photoOutput?.supportedFlashModes else {
782
+ throw CameraControllerError.noCamerasAvailable
783
+ }
784
+
785
+ for flashMode in supportedFlashModes {
786
+ var flashModeValue: String?
787
+ switch flashMode {
788
+ case AVCaptureDevice.FlashMode.off:
789
+ flashModeValue = "off"
790
+ case AVCaptureDevice.FlashMode.on:
791
+ flashModeValue = "on"
792
+ case AVCaptureDevice.FlashMode.auto:
793
+ flashModeValue = "auto"
794
+ default: break
795
+ }
796
+ if flashModeValue != nil {
797
+ supportedFlashModesAsStrings.append(flashModeValue!)
798
+ }
799
+ }
800
+ }
801
+ if device.hasTorch {
802
+ supportedFlashModesAsStrings.append("torch")
803
+ }
804
+ return supportedFlashModesAsStrings
805
+
806
+ }
807
+ func getHorizontalFov() throws -> Float {
808
+ var currentCamera: AVCaptureDevice?
809
+ switch currentCameraPosition {
810
+ case .front:
811
+ currentCamera = self.frontCamera!
812
+ case .rear:
813
+ currentCamera = self.rearCamera!
814
+ default: break
815
+ }
816
+
817
+ guard
818
+ let device = currentCamera
819
+ else {
820
+ throw CameraControllerError.noCamerasAvailable
821
+ }
822
+
823
+ // Get the active format and field of view
824
+ let activeFormat = device.activeFormat
825
+ let fov = activeFormat.videoFieldOfView
826
+
827
+ // Adjust for current zoom level
828
+ let zoomFactor = device.videoZoomFactor
829
+ let adjustedFov = fov / Float(zoomFactor)
830
+
831
+ return adjustedFov
832
+ }
833
+ func setFlashMode(flashMode: AVCaptureDevice.FlashMode) throws {
834
+ var currentCamera: AVCaptureDevice?
835
+ switch currentCameraPosition {
836
+ case .front:
837
+ currentCamera = self.frontCamera!
838
+ case .rear:
839
+ currentCamera = self.rearCamera!
840
+ default: break
841
+ }
842
+
843
+ guard let device = currentCamera else {
844
+ throw CameraControllerError.noCamerasAvailable
845
+ }
846
+
847
+ guard let supportedFlashModes: [AVCaptureDevice.FlashMode] = self.photoOutput?.supportedFlashModes else {
848
+ throw CameraControllerError.invalidOperation
849
+ }
850
+ if supportedFlashModes.contains(flashMode) {
851
+ do {
852
+ try device.lockForConfiguration()
853
+
854
+ if device.hasTorch && device.isTorchAvailable && device.torchMode == AVCaptureDevice.TorchMode.on {
855
+ device.torchMode = AVCaptureDevice.TorchMode.off
856
+ }
857
+ self.flashMode = flashMode
858
+ let photoSettings = AVCapturePhotoSettings()
859
+ photoSettings.flashMode = flashMode
860
+ self.photoOutput?.photoSettingsForSceneMonitoring = photoSettings
861
+
862
+ device.unlockForConfiguration()
863
+ } catch {
864
+ throw CameraControllerError.invalidOperation
865
+ }
866
+ } else {
867
+ throw CameraControllerError.invalidOperation
868
+ }
869
+ }
870
+
871
+ func setTorchMode() throws {
872
+ var currentCamera: AVCaptureDevice?
873
+ switch currentCameraPosition {
874
+ case .front:
875
+ currentCamera = self.frontCamera!
876
+ case .rear:
877
+ currentCamera = self.rearCamera!
878
+ default: break
879
+ }
880
+
881
+ guard
882
+ let device = currentCamera,
883
+ device.hasTorch,
884
+ device.isTorchAvailable
885
+ else {
886
+ throw CameraControllerError.invalidOperation
887
+ }
888
+
889
+ do {
890
+ try device.lockForConfiguration()
891
+ if device.isTorchModeSupported(AVCaptureDevice.TorchMode.on) {
892
+ device.torchMode = AVCaptureDevice.TorchMode.on
893
+ } else if device.isTorchModeSupported(AVCaptureDevice.TorchMode.auto) {
894
+ device.torchMode = AVCaptureDevice.TorchMode.auto
895
+ } else {
896
+ device.torchMode = AVCaptureDevice.TorchMode.off
897
+ }
898
+ device.unlockForConfiguration()
899
+ } catch {
900
+ throw CameraControllerError.invalidOperation
901
+ }
902
+ }
903
+
904
+ func getZoom() throws -> (min: Float, max: Float, current: Float) {
905
+ var currentCamera: AVCaptureDevice?
906
+ switch currentCameraPosition {
907
+ case .front:
908
+ currentCamera = self.frontCamera
909
+ case .rear:
910
+ currentCamera = self.rearCamera
911
+ default: break
912
+ }
913
+
914
+ guard let device = currentCamera else {
915
+ throw CameraControllerError.noCamerasAvailable
916
+ }
917
+
918
+ let effectiveMaxZoom = min(device.maxAvailableVideoZoomFactor, self.saneMaxZoomFactor)
919
+
920
+ return (
921
+ min: Float(device.minAvailableVideoZoomFactor),
922
+ max: Float(effectiveMaxZoom),
923
+ current: Float(device.videoZoomFactor)
924
+ )
925
+ }
926
+
927
+ func setZoom(level: CGFloat, ramp: Bool, autoFocus: Bool = true) throws {
928
+ var currentCamera: AVCaptureDevice?
929
+ switch currentCameraPosition {
930
+ case .front:
931
+ currentCamera = self.frontCamera
932
+ case .rear:
933
+ currentCamera = self.rearCamera
934
+ default: break
935
+ }
936
+
937
+ guard let device = currentCamera else {
938
+ throw CameraControllerError.noCamerasAvailable
939
+ }
940
+
941
+ let effectiveMaxZoom = min(device.maxAvailableVideoZoomFactor, self.saneMaxZoomFactor)
942
+ let zoomLevel = max(device.minAvailableVideoZoomFactor, min(level, effectiveMaxZoom))
943
+
944
+ do {
945
+ try device.lockForConfiguration()
946
+
947
+ if ramp {
948
+ // Use a very fast ramp rate for immediate response
949
+ device.ramp(toVideoZoomFactor: zoomLevel, withRate: 8.0)
950
+ } else {
951
+ device.videoZoomFactor = zoomLevel
952
+ }
953
+
954
+ device.unlockForConfiguration()
955
+
956
+ // Update our internal zoom factor tracking
957
+ self.zoomFactor = zoomLevel
958
+
959
+ // Trigger autofocus after zoom if requested
960
+ // if autoFocus {
961
+ // self.triggerAutoFocus()
962
+ // }
963
+ } catch {
964
+ throw CameraControllerError.invalidOperation
965
+ }
966
+ }
967
+
968
+ private func triggerAutoFocus() {
969
+ var currentCamera: AVCaptureDevice?
970
+ switch currentCameraPosition {
971
+ case .front:
972
+ currentCamera = self.frontCamera
973
+ case .rear:
974
+ currentCamera = self.rearCamera
975
+ default: break
976
+ }
977
+
978
+ guard let device = currentCamera else {
979
+ return
980
+ }
981
+
982
+ // Focus on the center of the preview (0.5, 0.5)
983
+ let centerPoint = CGPoint(x: 0.5, y: 0.5)
984
+
985
+ do {
986
+ try device.lockForConfiguration()
987
+
988
+ // Set focus mode to auto if supported
989
+ if device.isFocusModeSupported(.autoFocus) {
990
+ device.focusMode = .autoFocus
991
+ if device.isFocusPointOfInterestSupported {
992
+ device.focusPointOfInterest = centerPoint
993
+ }
994
+ } else if device.isFocusModeSupported(.continuousAutoFocus) {
995
+ device.focusMode = .continuousAutoFocus
996
+ if device.isFocusPointOfInterestSupported {
997
+ device.focusPointOfInterest = centerPoint
998
+ }
999
+ }
1000
+
1001
+ // Also set exposure point if supported
1002
+ if device.isExposurePointOfInterestSupported && device.isExposureModeSupported(.autoExpose) {
1003
+ device.exposureMode = .autoExpose
1004
+ device.exposurePointOfInterest = centerPoint
1005
+ } else if device.isExposureModeSupported(.continuousAutoExposure) {
1006
+ device.exposureMode = .continuousAutoExposure
1007
+ if device.isExposurePointOfInterestSupported {
1008
+ device.exposurePointOfInterest = centerPoint
1009
+ }
1010
+ }
1011
+
1012
+ device.unlockForConfiguration()
1013
+ } catch {
1014
+ // Silently ignore errors during autofocus
1015
+ }
1016
+ }
1017
+
1018
+ func setFocus(at point: CGPoint, showIndicator: Bool = false, in view: UIView? = nil) throws {
1019
+ // Validate that coordinates are within bounds (0-1 range for device coordinates)
1020
+ if point.x < 0 || point.x > 1 || point.y < 0 || point.y > 1 {
1021
+ print("setFocus: Coordinates out of bounds - x: \(point.x), y: \(point.y)")
1022
+ throw CameraControllerError.invalidOperation
1023
+ }
1024
+
1025
+ var currentCamera: AVCaptureDevice?
1026
+ switch currentCameraPosition {
1027
+ case .front:
1028
+ currentCamera = self.frontCamera
1029
+ case .rear:
1030
+ currentCamera = self.rearCamera
1031
+ default: break
1032
+ }
1033
+
1034
+ guard let device = currentCamera else {
1035
+ throw CameraControllerError.noCamerasAvailable
1036
+ }
1037
+
1038
+ guard device.isFocusPointOfInterestSupported else {
1039
+ // Device doesn't support focus point of interest
1040
+ return
1041
+ }
1042
+
1043
+ // Show focus indicator if requested and view is provided - only after validation
1044
+ if showIndicator, let view = view, let previewLayer = self.previewLayer {
1045
+ // Convert the device point to layer point for indicator display
1046
+ let layerPoint = previewLayer.layerPointConverted(fromCaptureDevicePoint: point)
1047
+ showFocusIndicator(at: layerPoint, in: view)
1048
+ }
1049
+
1050
+ do {
1051
+ try device.lockForConfiguration()
1052
+
1053
+ // Set focus mode to auto if supported
1054
+ if device.isFocusModeSupported(.autoFocus) {
1055
+ device.focusMode = .autoFocus
1056
+ } else if device.isFocusModeSupported(.continuousAutoFocus) {
1057
+ device.focusMode = .continuousAutoFocus
1058
+ }
1059
+
1060
+ // Set the focus point
1061
+ device.focusPointOfInterest = point
1062
+
1063
+ // Also set exposure point if supported
1064
+ if device.isExposurePointOfInterestSupported && device.isExposureModeSupported(.autoExpose) {
1065
+ device.exposureMode = .autoExpose
1066
+ device.exposurePointOfInterest = point
1067
+ }
1068
+
1069
+ device.unlockForConfiguration()
1070
+ } catch {
1071
+ throw CameraControllerError.unknown
1072
+ }
1073
+ }
1074
+
1075
+ func getFlashMode() throws -> String {
1076
+ switch self.flashMode {
1077
+ case .off:
1078
+ return "off"
1079
+ case .on:
1080
+ return "on"
1081
+ case .auto:
1082
+ return "auto"
1083
+ @unknown default:
1084
+ return "off"
1085
+ }
1086
+ }
1087
+
1088
+ func getCurrentDeviceId() throws -> String {
1089
+ var currentCamera: AVCaptureDevice?
1090
+ switch currentCameraPosition {
1091
+ case .front:
1092
+ currentCamera = self.frontCamera
1093
+ case .rear:
1094
+ currentCamera = self.rearCamera
1095
+ default:
1096
+ break
1097
+ }
1098
+
1099
+ guard let device = currentCamera else {
1100
+ throw CameraControllerError.noCamerasAvailable
1101
+ }
1102
+
1103
+ return device.uniqueID
1104
+ }
1105
+
1106
+ func getCurrentLensInfo() throws -> (focalLength: Float, deviceType: String, baseZoomRatio: Float) {
1107
+ var currentCamera: AVCaptureDevice?
1108
+ switch currentCameraPosition {
1109
+ case .front:
1110
+ currentCamera = self.frontCamera
1111
+ case .rear:
1112
+ currentCamera = self.rearCamera
1113
+ default:
1114
+ break
1115
+ }
1116
+
1117
+ guard let device = currentCamera else {
1118
+ throw CameraControllerError.noCamerasAvailable
1119
+ }
1120
+
1121
+ var deviceType = "wideAngle"
1122
+ var baseZoomRatio: Float = 1.0
1123
+
1124
+ switch device.deviceType {
1125
+ case .builtInWideAngleCamera:
1126
+ deviceType = "wideAngle"
1127
+ baseZoomRatio = 1.0
1128
+ case .builtInUltraWideCamera:
1129
+ deviceType = "ultraWide"
1130
+ baseZoomRatio = 0.5
1131
+ case .builtInTelephotoCamera:
1132
+ deviceType = "telephoto"
1133
+ baseZoomRatio = 2.0
1134
+ case .builtInDualCamera:
1135
+ deviceType = "dual"
1136
+ baseZoomRatio = 1.0
1137
+ case .builtInDualWideCamera:
1138
+ deviceType = "dualWide"
1139
+ baseZoomRatio = 1.0
1140
+ case .builtInTripleCamera:
1141
+ deviceType = "triple"
1142
+ baseZoomRatio = 1.0
1143
+ case .builtInTrueDepthCamera:
1144
+ deviceType = "trueDepth"
1145
+ baseZoomRatio = 1.0
1146
+ default:
1147
+ deviceType = "wideAngle"
1148
+ baseZoomRatio = 1.0
1149
+ }
1150
+
1151
+ // Approximate focal length for mobile devices
1152
+ let focalLength: Float = 4.25
1153
+
1154
+ return (focalLength: focalLength, deviceType: deviceType, baseZoomRatio: baseZoomRatio)
1155
+ }
1156
+
1157
+ func swapToDevice(deviceId: String) throws {
1158
+ guard let captureSession = self.captureSession else {
1159
+ throw CameraControllerError.captureSessionIsMissing
1160
+ }
1161
+
1162
+ // Find the device with the specified deviceId
1163
+ let allDevices = AVCaptureDevice.DiscoverySession(
1164
+ deviceTypes: [.builtInWideAngleCamera, .builtInUltraWideCamera, .builtInTelephotoCamera, .builtInDualCamera, .builtInDualWideCamera, .builtInTripleCamera, .builtInTrueDepthCamera],
1165
+ mediaType: .video,
1166
+ position: .unspecified
1167
+ ).devices
1168
+
1169
+ guard let targetDevice = allDevices.first(where: { $0.uniqueID == deviceId }) else {
1170
+ throw CameraControllerError.noCamerasAvailable
1171
+ }
1172
+
1173
+ // Store the current running state
1174
+ let wasRunning = captureSession.isRunning
1175
+ if wasRunning {
1176
+ captureSession.stopRunning()
1177
+ }
1178
+
1179
+ // Begin configuration
1180
+ captureSession.beginConfiguration()
1181
+ defer {
1182
+ captureSession.commitConfiguration()
1183
+ // Restart the session if it was running before
1184
+ if wasRunning {
1185
+ captureSession.startRunning()
1186
+ }
1187
+ }
1188
+
1189
+ // Store audio input if it exists
1190
+ let audioInput = captureSession.inputs.first { ($0 as? AVCaptureDeviceInput)?.device.hasMediaType(.audio) ?? false }
1191
+
1192
+ // Remove only video inputs
1193
+ captureSession.inputs.forEach { input in
1194
+ if (input as? AVCaptureDeviceInput)?.device.hasMediaType(.video) ?? false {
1195
+ captureSession.removeInput(input)
1196
+ }
1197
+ }
1198
+
1199
+ // Configure the new device
1200
+ let newInput = try AVCaptureDeviceInput(device: targetDevice)
1201
+
1202
+ if captureSession.canAddInput(newInput) {
1203
+ captureSession.addInput(newInput)
1204
+
1205
+ // Update camera references based on device position
1206
+ if targetDevice.position == .front {
1207
+ self.frontCameraInput = newInput
1208
+ self.frontCamera = targetDevice
1209
+ self.currentCameraPosition = .front
1210
+ } else {
1211
+ self.rearCameraInput = newInput
1212
+ self.rearCamera = targetDevice
1213
+ self.currentCameraPosition = .rear
1214
+
1215
+ // Configure rear camera
1216
+ try targetDevice.lockForConfiguration()
1217
+ if targetDevice.isFocusModeSupported(.continuousAutoFocus) {
1218
+ targetDevice.focusMode = .continuousAutoFocus
1219
+ }
1220
+ targetDevice.unlockForConfiguration()
1221
+ }
1222
+ } else {
1223
+ throw CameraControllerError.invalidOperation
1224
+ }
1225
+
1226
+ // Re-add audio input if it existed
1227
+ if let audioInput = audioInput, captureSession.canAddInput(audioInput) {
1228
+ captureSession.addInput(audioInput)
1229
+ }
1230
+
1231
+ // Update video orientation
1232
+ self.updateVideoOrientation()
1233
+ }
1234
+
1235
+ func cleanup() {
1236
+ if let captureSession = self.captureSession {
1237
+ captureSession.stopRunning()
1238
+ captureSession.inputs.forEach { captureSession.removeInput($0) }
1239
+ captureSession.outputs.forEach { captureSession.removeOutput($0) }
1240
+ }
1241
+
1242
+ self.previewLayer?.removeFromSuperlayer()
1243
+ self.previewLayer = nil
1244
+
1245
+ self.focusIndicatorView?.removeFromSuperview()
1246
+ self.focusIndicatorView = nil
1247
+
1248
+ self.frontCameraInput = nil
1249
+ self.rearCameraInput = nil
1250
+ self.audioInput = nil
1251
+
1252
+ self.frontCamera = nil
1253
+ self.rearCamera = nil
1254
+ self.audioDevice = nil
1255
+ self.allDiscoveredDevices = []
1256
+
1257
+ self.dataOutput = nil
1258
+ self.photoOutput = nil
1259
+ self.fileVideoOutput = nil
1260
+
1261
+ self.captureSession = nil
1262
+ self.currentCameraPosition = nil
1263
+
1264
+ // Reset output preparation status
1265
+ self.outputsPrepared = false
1266
+
1267
+ // Reset first frame detection
1268
+ self.hasReceivedFirstFrame = false
1269
+ self.firstFrameReadyCallback = nil
1270
+ }
1271
+
1272
+ func captureVideo() throws {
1273
+ guard let captureSession = self.captureSession, captureSession.isRunning else {
1274
+ throw CameraControllerError.captureSessionIsMissing
1275
+ }
1276
+ guard let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else {
1277
+ throw CameraControllerError.cannotFindDocumentsDirectory
1278
+ }
1279
+
1280
+ guard let fileVideoOutput = self.fileVideoOutput else {
1281
+ throw CameraControllerError.fileVideoOutputNotFound
1282
+ }
1283
+
1284
+ // cpcp_video_A6C01203 - portrait
1285
+ //
1286
+ if let connection = fileVideoOutput.connection(with: .video) {
1287
+ switch UIDevice.current.orientation {
1288
+ case .landscapeRight:
1289
+ connection.videoOrientation = .landscapeLeft
1290
+ case .landscapeLeft:
1291
+ connection.videoOrientation = .landscapeRight
1292
+ case .portrait:
1293
+ connection.videoOrientation = .portrait
1294
+ case .portraitUpsideDown:
1295
+ connection.videoOrientation = .portraitUpsideDown
1296
+ default:
1297
+ connection.videoOrientation = .portrait
1298
+ }
1299
+ }
1300
+
1301
+ let identifier = UUID()
1302
+ let randomIdentifier = identifier.uuidString.replacingOccurrences(of: "-", with: "")
1303
+ let finalIdentifier = String(randomIdentifier.prefix(8))
1304
+ let fileName="cpcp_video_"+finalIdentifier+".mp4"
1305
+
1306
+ let fileUrl = documentsDirectory.appendingPathComponent(fileName)
1307
+ try? FileManager.default.removeItem(at: fileUrl)
1308
+
1309
+ // Start recording video
1310
+ fileVideoOutput.startRecording(to: fileUrl, recordingDelegate: self)
1311
+
1312
+ // Save the file URL for later use
1313
+ self.videoFileURL = fileUrl
1314
+ }
1315
+
1316
+ func stopRecording(completion: @escaping (URL?, Error?) -> Void) {
1317
+ guard let captureSession = self.captureSession, captureSession.isRunning else {
1318
+ completion(nil, CameraControllerError.captureSessionIsMissing)
1319
+ return
1320
+ }
1321
+ guard let fileVideoOutput = self.fileVideoOutput else {
1322
+ completion(nil, CameraControllerError.fileVideoOutputNotFound)
1323
+ return
1324
+ }
1325
+
1326
+ // Stop recording video
1327
+ fileVideoOutput.stopRecording()
1328
+
1329
+ // Return the video file URL in the completion handler
1330
+ completion(self.videoFileURL, nil)
1331
+ }
1332
+ }
1333
+
1334
+ extension CameraController: UIGestureRecognizerDelegate {
1335
+ func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
1336
+ return true
1337
+ }
1338
+
1339
+ @objc
1340
+ func handleTap(_ tap: UITapGestureRecognizer) {
1341
+ guard let device = self.currentCameraPosition == .rear ? rearCamera : frontCamera else { return }
1342
+
1343
+ let point = tap.location(in: tap.view)
1344
+ let devicePoint = self.previewLayer?.captureDevicePointConverted(fromLayerPoint: point)
1345
+
1346
+ // Show focus indicator at the tap point
1347
+ if let view = tap.view {
1348
+ showFocusIndicator(at: point, in: view)
1349
+ }
1350
+
1351
+ do {
1352
+ try device.lockForConfiguration()
1353
+ defer { device.unlockForConfiguration() }
1354
+
1355
+ let focusMode = AVCaptureDevice.FocusMode.autoFocus
1356
+ if device.isFocusPointOfInterestSupported && device.isFocusModeSupported(focusMode) {
1357
+ device.focusPointOfInterest = CGPoint(x: CGFloat(devicePoint?.x ?? 0), y: CGFloat(devicePoint?.y ?? 0))
1358
+ device.focusMode = focusMode
1359
+ }
1360
+
1361
+ let exposureMode = AVCaptureDevice.ExposureMode.autoExpose
1362
+ if device.isExposurePointOfInterestSupported && device.isExposureModeSupported(exposureMode) {
1363
+ device.exposurePointOfInterest = CGPoint(x: CGFloat(devicePoint?.x ?? 0), y: CGFloat(devicePoint?.y ?? 0))
1364
+ device.exposureMode = exposureMode
1365
+ }
1366
+ } catch {
1367
+ debugPrint(error)
1368
+ }
1369
+ }
1370
+
1371
+ private func showFocusIndicator(at point: CGPoint, in view: UIView) {
1372
+ // Remove any existing focus indicator
1373
+ focusIndicatorView?.removeFromSuperview()
1374
+
1375
+ // Create a new focus indicator
1376
+ let indicator = UIView(frame: CGRect(x: 0, y: 0, width: 80, height: 80))
1377
+ indicator.center = point
1378
+ indicator.layer.borderColor = UIColor.yellow.cgColor
1379
+ indicator.layer.borderWidth = 2.0
1380
+ indicator.layer.cornerRadius = 40
1381
+ indicator.backgroundColor = UIColor.clear
1382
+ indicator.alpha = 0
1383
+ indicator.transform = CGAffineTransform(scaleX: 1.5, y: 1.5)
1384
+
1385
+ // Add inner circle for better visibility
1386
+ let innerCircle = UIView(frame: CGRect(x: 20, y: 20, width: 40, height: 40))
1387
+ innerCircle.layer.borderColor = UIColor.yellow.cgColor
1388
+ innerCircle.layer.borderWidth = 1.0
1389
+ innerCircle.layer.cornerRadius = 20
1390
+ innerCircle.backgroundColor = UIColor.clear
1391
+ indicator.addSubview(innerCircle)
1392
+
1393
+ view.addSubview(indicator)
1394
+ focusIndicatorView = indicator
1395
+
1396
+ // Animate the focus indicator
1397
+ UIView.animate(withDuration: 0.15, animations: {
1398
+ indicator.alpha = 1.0
1399
+ indicator.transform = CGAffineTransform.identity
1400
+ }) { _ in
1401
+ // Keep the indicator visible for a moment
1402
+ UIView.animate(withDuration: 0.2, delay: 0.5, options: [], animations: {
1403
+ indicator.alpha = 0.3
1404
+ }) { _ in
1405
+ // Fade out and remove
1406
+ UIView.animate(withDuration: 0.3, delay: 0.2, options: [], animations: {
1407
+ indicator.alpha = 0
1408
+ indicator.transform = CGAffineTransform(scaleX: 0.8, y: 0.8)
1409
+ }) { _ in
1410
+ indicator.removeFromSuperview()
1411
+ if self.focusIndicatorView == indicator {
1412
+ self.focusIndicatorView = nil
1413
+ }
1414
+ }
1415
+ }
1416
+ }
1417
+ }
1418
+
1419
+ @objc
1420
+ private func handlePinch(_ pinch: UIPinchGestureRecognizer) {
1421
+ guard let device = self.currentCameraPosition == .rear ? rearCamera : frontCamera else { return }
1422
+
1423
+ let effectiveMaxZoom = min(device.maxAvailableVideoZoomFactor, self.saneMaxZoomFactor)
1424
+ func minMaxZoom(_ factor: CGFloat) -> CGFloat { return max(device.minAvailableVideoZoomFactor, min(factor, effectiveMaxZoom)) }
1425
+
1426
+ switch pinch.state {
1427
+ case .began:
1428
+ // Store the initial zoom factor when pinch begins
1429
+ zoomFactor = device.videoZoomFactor
1430
+
1431
+ case .changed:
1432
+ // Throttle zoom updates to prevent excessive CPU usage
1433
+ let currentTime = CACurrentMediaTime()
1434
+ guard currentTime - lastZoomUpdateTime >= zoomUpdateThrottle else { return }
1435
+ lastZoomUpdateTime = currentTime
1436
+
1437
+ // Calculate new zoom factor based on pinch scale
1438
+ let newScaleFactor = minMaxZoom(pinch.scale * zoomFactor)
1439
+
1440
+ // Use ramping for smooth zoom transitions during pinch
1441
+ // This provides much smoother performance than direct setting
1442
+ do {
1443
+ try device.lockForConfiguration()
1444
+ // Use a very fast ramp rate for immediate response
1445
+ device.ramp(toVideoZoomFactor: newScaleFactor, withRate: 5.0)
1446
+ device.unlockForConfiguration()
1447
+ } catch {
1448
+ debugPrint("Failed to set zoom: \(error)")
1449
+ }
1450
+
1451
+ case .ended:
1452
+ // Update our internal zoom factor tracking
1453
+ zoomFactor = device.videoZoomFactor
1454
+
1455
+ default: break
1456
+ }
1457
+ }
1458
+ }
1459
+
1460
+ extension CameraController: AVCapturePhotoCaptureDelegate {
1461
+ public func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) {
1462
+ if let error = error {
1463
+ self.photoCaptureCompletionBlock?(nil, error)
1464
+ return
1465
+ }
1466
+
1467
+ // Get the photo data using the modern API
1468
+ guard let imageData = photo.fileDataRepresentation() else {
1469
+ self.photoCaptureCompletionBlock?(nil, CameraControllerError.unknown)
1470
+ return
1471
+ }
1472
+
1473
+ guard let image = UIImage(data: imageData) else {
1474
+ self.photoCaptureCompletionBlock?(nil, CameraControllerError.unknown)
1475
+ return
1476
+ }
1477
+
1478
+ self.photoCaptureCompletionBlock?(image.fixedOrientation(), nil)
1479
+ }
1480
+ }
1481
+
1482
+ extension CameraController: AVCaptureVideoDataOutputSampleBufferDelegate {
1483
+ func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
1484
+ // Check if we're waiting for the first frame
1485
+ if !hasReceivedFirstFrame, let firstFrameCallback = firstFrameReadyCallback {
1486
+ hasReceivedFirstFrame = true
1487
+ firstFrameCallback()
1488
+ firstFrameReadyCallback = nil
1489
+ // If no capture is in progress, we can return early
1490
+ if sampleBufferCaptureCompletionBlock == nil {
1491
+ return
1492
+ }
1493
+ }
1494
+
1495
+ guard let completion = sampleBufferCaptureCompletionBlock else { return }
1496
+
1497
+ guard let imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else {
1498
+ completion(nil, CameraControllerError.unknown)
1499
+ return
1500
+ }
1501
+
1502
+ CVPixelBufferLockBaseAddress(imageBuffer, .readOnly)
1503
+ defer { CVPixelBufferUnlockBaseAddress(imageBuffer, .readOnly) }
1504
+
1505
+ let baseAddress = CVPixelBufferGetBaseAddress(imageBuffer)
1506
+ let bytesPerRow = CVPixelBufferGetBytesPerRow(imageBuffer)
1507
+ let width = CVPixelBufferGetWidth(imageBuffer)
1508
+ let height = CVPixelBufferGetHeight(imageBuffer)
1509
+ let colorSpace = CGColorSpaceCreateDeviceRGB()
1510
+ let bitmapInfo: UInt32 = CGBitmapInfo.byteOrder32Little.rawValue |
1511
+ CGImageAlphaInfo.premultipliedFirst.rawValue
1512
+
1513
+ let context = CGContext(
1514
+ data: baseAddress,
1515
+ width: width,
1516
+ height: height,
1517
+ bitsPerComponent: 8,
1518
+ bytesPerRow: bytesPerRow,
1519
+ space: colorSpace,
1520
+ bitmapInfo: bitmapInfo
1521
+ )
1522
+
1523
+ guard let cgImage = context?.makeImage() else {
1524
+ completion(nil, CameraControllerError.unknown)
1525
+ return
1526
+ }
1527
+
1528
+ let image = UIImage(cgImage: cgImage)
1529
+ completion(image.fixedOrientation(), nil)
1530
+
1531
+ sampleBufferCaptureCompletionBlock = nil
1532
+ }
1533
+ }
1534
+
1535
+ enum CameraControllerError: Swift.Error {
1536
+ case captureSessionAlreadyRunning
1537
+ case captureSessionIsMissing
1538
+ case inputsAreInvalid
1539
+ case invalidOperation
1540
+ case noCamerasAvailable
1541
+ case cannotFindDocumentsDirectory
1542
+ case fileVideoOutputNotFound
1543
+ case unknown
1544
+ case invalidZoomLevel(min: CGFloat, max: CGFloat, requested: CGFloat)
1545
+ }
1546
+
1547
+ public enum CameraPosition {
1548
+ case front
1549
+ case rear
1550
+ }
1551
+
1552
+ extension CameraControllerError: LocalizedError {
1553
+ public var errorDescription: String? {
1554
+ switch self {
1555
+ case .captureSessionAlreadyRunning:
1556
+ return NSLocalizedString("Capture Session is Already Running", comment: "Capture Session Already Running")
1557
+ case .captureSessionIsMissing:
1558
+ return NSLocalizedString("Capture Session is Missing", comment: "Capture Session Missing")
1559
+ case .inputsAreInvalid:
1560
+ return NSLocalizedString("Inputs Are Invalid", comment: "Inputs Are Invalid")
1561
+ case .invalidOperation:
1562
+ return NSLocalizedString("Invalid Operation", comment: "invalid Operation")
1563
+ case .noCamerasAvailable:
1564
+ return NSLocalizedString("Failed to access device camera(s)", comment: "No Cameras Available")
1565
+ case .unknown:
1566
+ return NSLocalizedString("Unknown", comment: "Unknown")
1567
+ case .cannotFindDocumentsDirectory:
1568
+ return NSLocalizedString("Cannot find documents directory", comment: "This should never happen")
1569
+ case .fileVideoOutputNotFound:
1570
+ return NSLocalizedString("Video recording is not available. Make sure the camera is properly initialized.", comment: "Video recording not available")
1571
+ case .invalidZoomLevel(let min, let max, let requested):
1572
+ return NSLocalizedString("Invalid zoom level. Must be between \(min) and \(max). Requested: \(requested)", comment: "Invalid Zoom Level")
1573
+ }
1574
+ }
1575
+ }
1576
+
1577
+ extension UIImage {
1578
+
1579
+ func fixedOrientation() -> UIImage? {
1580
+
1581
+ guard imageOrientation != UIImage.Orientation.up else {
1582
+ // This is default orientation, don't need to do anything
1583
+ return self.copy() as? UIImage
1584
+ }
1585
+
1586
+ guard let cgImage = self.cgImage else {
1587
+ // CGImage is not available
1588
+ return nil
1589
+ }
1590
+
1591
+ guard let colorSpace = cgImage.colorSpace, let ctx = CGContext(data: nil,
1592
+ width: Int(size.width), height: Int(size.height),
1593
+ bitsPerComponent: cgImage.bitsPerComponent, bytesPerRow: 0,
1594
+ space: colorSpace, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else {
1595
+ return nil // Not able to create CGContext
1596
+ }
1597
+
1598
+ var transform: CGAffineTransform = CGAffineTransform.identity
1599
+ switch imageOrientation {
1600
+ case .down, .downMirrored:
1601
+ transform = transform.translatedBy(x: size.width, y: size.height)
1602
+ transform = transform.rotated(by: CGFloat.pi)
1603
+ print("down")
1604
+ case .left, .leftMirrored:
1605
+ transform = transform.translatedBy(x: size.width, y: 0)
1606
+ transform = transform.rotated(by: CGFloat.pi / 2.0)
1607
+ print("left")
1608
+ case .right, .rightMirrored:
1609
+ transform = transform.translatedBy(x: 0, y: size.height)
1610
+ transform = transform.rotated(by: CGFloat.pi / -2.0)
1611
+ print("right")
1612
+ case .up, .upMirrored:
1613
+ break
1614
+ @unknown default:
1615
+ break
1616
+ }
1617
+
1618
+ // Flip image one more time if needed to, this is to prevent flipped image
1619
+ switch imageOrientation {
1620
+ case .upMirrored, .downMirrored:
1621
+ transform.translatedBy(x: size.width, y: 0)
1622
+ transform.scaledBy(x: -1, y: 1)
1623
+ case .leftMirrored, .rightMirrored:
1624
+ transform.translatedBy(x: size.height, y: 0)
1625
+ transform.scaledBy(x: -1, y: 1)
1626
+ case .up, .down, .left, .right:
1627
+ break
1628
+ @unknown default:
1629
+ break
1630
+ }
1631
+
1632
+ ctx.concatenate(transform)
1633
+
1634
+ switch imageOrientation {
1635
+ case .left, .leftMirrored, .right, .rightMirrored:
1636
+ if let cgImage = self.cgImage {
1637
+ ctx.draw(cgImage, in: CGRect(x: 0, y: 0, width: size.height, height: size.width))
1638
+ }
1639
+ default:
1640
+ if let cgImage = self.cgImage {
1641
+ ctx.draw(cgImage, in: CGRect(x: 0, y: 0, width: size.width, height: size.height))
1642
+ }
1643
+ }
1644
+ guard let newCGImage = ctx.makeImage() else { return nil }
1645
+ return UIImage.init(cgImage: newCGImage, scale: 1, orientation: .up)
1646
+ }
1647
+ }
1648
+
1649
+ extension CameraController: AVCaptureFileOutputRecordingDelegate {
1650
+ func fileOutput(_ output: AVCaptureFileOutput, didFinishRecordingTo outputFileURL: URL, from connections: [AVCaptureConnection], error: Error?) {
1651
+ if let error = error {
1652
+ print("Error recording movie: \(error.localizedDescription)")
1653
+ } else {
1654
+ print("Movie recorded successfully: \(outputFileURL)")
1655
+ // You can save the file to the library, upload it, etc.
1656
+ }
1657
+ }
1658
+ }