@capgo/camera-preview 7.4.0-beta.2 → 7.4.0-beta.20
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.
- package/README.md +212 -35
- package/android/.gradle/8.14.2/checksums/checksums.lock +0 -0
- package/android/.gradle/8.14.2/checksums/md5-checksums.bin +0 -0
- package/android/.gradle/8.14.2/checksums/sha1-checksums.bin +0 -0
- package/android/.gradle/8.14.2/executionHistory/executionHistory.bin +0 -0
- package/android/.gradle/8.14.2/executionHistory/executionHistory.lock +0 -0
- package/android/.gradle/8.14.2/fileHashes/fileHashes.bin +0 -0
- package/android/.gradle/8.14.2/fileHashes/fileHashes.lock +0 -0
- package/android/.gradle/8.14.2/fileHashes/resourceHashesCache.bin +0 -0
- package/android/.gradle/buildOutputCleanup/buildOutputCleanup.lock +0 -0
- package/android/.gradle/file-system.probe +0 -0
- package/android/build.gradle +3 -1
- package/android/src/main/AndroidManifest.xml +1 -4
- package/android/src/main/java/com/ahm/capacitor/camera/preview/CameraPreview.java +731 -83
- package/android/src/main/java/com/ahm/capacitor/camera/preview/CameraXView.java +2813 -805
- package/android/src/main/java/com/ahm/capacitor/camera/preview/GridOverlayView.java +112 -0
- package/android/src/main/java/com/ahm/capacitor/camera/preview/model/CameraDevice.java +55 -46
- package/android/src/main/java/com/ahm/capacitor/camera/preview/model/CameraLens.java +61 -52
- package/android/src/main/java/com/ahm/capacitor/camera/preview/model/CameraSessionConfiguration.java +161 -59
- package/android/src/main/java/com/ahm/capacitor/camera/preview/model/LensInfo.java +29 -23
- package/android/src/main/java/com/ahm/capacitor/camera/preview/model/ZoomFactors.java +24 -23
- package/dist/docs.json +292 -29
- package/dist/esm/definitions.d.ts +148 -13
- package/dist/esm/definitions.js.map +1 -1
- package/dist/esm/web.d.ts +52 -3
- package/dist/esm/web.js +555 -97
- package/dist/esm/web.js.map +1 -1
- package/dist/plugin.cjs.js +553 -97
- package/dist/plugin.cjs.js.map +1 -1
- package/dist/plugin.js +553 -97
- package/dist/plugin.js.map +1 -1
- package/ios/Sources/CapgoCameraPreview/CameraController.swift +888 -214
- package/ios/Sources/CapgoCameraPreview/GridOverlayView.swift +65 -0
- package/ios/Sources/CapgoCameraPreview/Plugin.swift +967 -250
- package/package.json +2 -2
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import AVFoundation
|
|
10
10
|
import UIKit
|
|
11
|
+
import CoreLocation
|
|
11
12
|
|
|
12
13
|
class CameraController: NSObject {
|
|
13
14
|
var captureSession: AVCaptureSession?
|
|
@@ -23,25 +24,37 @@ class CameraController: NSObject {
|
|
|
23
24
|
var rearCamera: AVCaptureDevice?
|
|
24
25
|
var rearCameraInput: AVCaptureDeviceInput?
|
|
25
26
|
|
|
27
|
+
var allDiscoveredDevices: [AVCaptureDevice] = []
|
|
28
|
+
|
|
26
29
|
var fileVideoOutput: AVCaptureMovieFileOutput?
|
|
27
30
|
|
|
28
31
|
var previewLayer: AVCaptureVideoPreviewLayer?
|
|
32
|
+
var gridOverlayView: GridOverlayView?
|
|
33
|
+
var focusIndicatorView: UIView?
|
|
29
34
|
|
|
30
35
|
var flashMode = AVCaptureDevice.FlashMode.off
|
|
31
36
|
var photoCaptureCompletionBlock: ((UIImage?, Error?) -> Void)?
|
|
32
37
|
|
|
33
38
|
var sampleBufferCaptureCompletionBlock: ((UIImage?, Error?) -> Void)?
|
|
34
|
-
|
|
35
|
-
|
|
39
|
+
|
|
40
|
+
// Add callback for detecting when first frame is ready
|
|
41
|
+
var firstFrameReadyCallback: (() -> Void)?
|
|
42
|
+
var hasReceivedFirstFrame = false
|
|
36
43
|
|
|
37
44
|
var audioDevice: AVCaptureDevice?
|
|
38
45
|
var audioInput: AVCaptureDeviceInput?
|
|
39
46
|
|
|
40
47
|
var zoomFactor: CGFloat = 1.0
|
|
48
|
+
private var lastZoomUpdateTime: TimeInterval = 0
|
|
49
|
+
private let zoomUpdateThrottle: TimeInterval = 1.0 / 60.0 // 60 FPS max
|
|
41
50
|
|
|
42
51
|
var videoFileURL: URL?
|
|
43
52
|
private let saneMaxZoomFactor: CGFloat = 25.5
|
|
44
53
|
|
|
54
|
+
// Track output preparation status
|
|
55
|
+
private var outputsPrepared: Bool = false
|
|
56
|
+
private let outputPreparationQueue = DispatchQueue(label: "camera.output.preparation", qos: .utility)
|
|
57
|
+
|
|
45
58
|
var isUsingMultiLensVirtualCamera: Bool {
|
|
46
59
|
guard let device = (currentCameraPosition == .rear) ? rearCamera : frontCamera else { return false }
|
|
47
60
|
// A rear multi-lens virtual camera will have a min zoom of 1.0 but support wider angles
|
|
@@ -50,215 +63,458 @@ class CameraController: NSObject {
|
|
|
50
63
|
}
|
|
51
64
|
|
|
52
65
|
extension CameraController {
|
|
53
|
-
func
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
66
|
+
func prepareFullSession() {
|
|
67
|
+
// Only prepare if we don't already have a session
|
|
68
|
+
guard self.captureSession == nil else { return }
|
|
69
|
+
|
|
70
|
+
print("[CameraPreview] Preparing full camera session in background")
|
|
71
|
+
|
|
72
|
+
// 1. Create and configure session
|
|
73
|
+
self.captureSession = AVCaptureSession()
|
|
74
|
+
|
|
75
|
+
// 2. Pre-configure session preset (can be changed later) - use medium for faster startup
|
|
76
|
+
if captureSession!.canSetSessionPreset(.medium) {
|
|
77
|
+
captureSession!.sessionPreset = .medium // Start with medium, upgrade later if needed
|
|
78
|
+
} else if captureSession!.canSetSessionPreset(.high) {
|
|
79
|
+
captureSession!.sessionPreset = .high
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 3. Discover cameras on-demand (only when needed for better startup performance)
|
|
83
|
+
// discoverAndConfigureCameras() - moved to lazy loading
|
|
84
|
+
|
|
85
|
+
// // 4. Pre-create outputs asynchronously to avoid blocking camera opening
|
|
86
|
+
// outputPreparationQueue.async { [weak self] in
|
|
87
|
+
// self?.prepareOutputs()
|
|
88
|
+
// }
|
|
89
|
+
|
|
90
|
+
print("[CameraPreview] Full session preparation complete - cameras will be discovered on-demand, outputs being prepared asynchronously")
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private func ensureCamerasDiscovered() {
|
|
94
|
+
// Rediscover cameras if the array is empty OR if the camera pointers are nil
|
|
95
|
+
guard allDiscoveredDevices.isEmpty || (rearCamera == nil && frontCamera == nil) else { return }
|
|
96
|
+
discoverAndConfigureCameras()
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private func discoverAndConfigureCameras() {
|
|
100
|
+
let deviceTypes: [AVCaptureDevice.DeviceType] = [
|
|
101
|
+
.builtInWideAngleCamera,
|
|
102
|
+
.builtInUltraWideCamera,
|
|
103
|
+
.builtInTelephotoCamera,
|
|
104
|
+
.builtInDualCamera,
|
|
105
|
+
.builtInDualWideCamera,
|
|
106
|
+
.builtInTripleCamera,
|
|
107
|
+
.builtInTrueDepthCamera
|
|
108
|
+
]
|
|
109
|
+
|
|
110
|
+
let session = AVCaptureDevice.DiscoverySession(deviceTypes: deviceTypes, mediaType: AVMediaType.video, position: .unspecified)
|
|
111
|
+
let cameras = session.devices.compactMap { $0 }
|
|
112
|
+
|
|
113
|
+
// Store all discovered devices for fast lookup later
|
|
114
|
+
self.allDiscoveredDevices = cameras
|
|
115
|
+
|
|
116
|
+
// Log all found devices for debugging
|
|
117
|
+
print("[CameraPreview] Found \(cameras.count) devices:")
|
|
118
|
+
for camera in cameras {
|
|
119
|
+
let constituentCount = camera.isVirtualDevice ? camera.constituentDevices.count : 1
|
|
120
|
+
print("[CameraPreview] - \(camera.localizedName) (Position: \(camera.position.rawValue), Virtual: \(camera.isVirtualDevice), Lenses: \(constituentCount))")
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Find best cameras
|
|
124
|
+
let rearVirtualDevices = cameras.filter { $0.position == .back && $0.isVirtualDevice }
|
|
125
|
+
let bestRearVirtualDevice = rearVirtualDevices.max { $0.constituentDevices.count < $1.constituentDevices.count }
|
|
126
|
+
|
|
127
|
+
self.frontCamera = cameras.first(where: { $0.position == .front })
|
|
128
|
+
|
|
129
|
+
if let bestCamera = bestRearVirtualDevice {
|
|
130
|
+
self.rearCamera = bestCamera
|
|
131
|
+
print("[CameraPreview] Selected best virtual rear camera: \(bestCamera.localizedName) with \(bestCamera.constituentDevices.count) physical cameras.")
|
|
132
|
+
} else if let firstRearCamera = cameras.first(where: { $0.position == .back }) {
|
|
133
|
+
self.rearCamera = firstRearCamera
|
|
134
|
+
print("[CameraPreview] WARN: No virtual rear camera found. Selected first available: \(firstRearCamera.localizedName)")
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Pre-configure focus modes
|
|
138
|
+
configureCameraFocus(camera: self.rearCamera)
|
|
139
|
+
configureCameraFocus(camera: self.frontCamera)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private func configureCameraFocus(camera: AVCaptureDevice?) {
|
|
143
|
+
guard let camera = camera else { return }
|
|
144
|
+
|
|
145
|
+
do {
|
|
146
|
+
try camera.lockForConfiguration()
|
|
147
|
+
if camera.isFocusModeSupported(.continuousAutoFocus) {
|
|
148
|
+
camera.focusMode = .continuousAutoFocus
|
|
79
149
|
}
|
|
150
|
+
camera.unlockForConfiguration()
|
|
151
|
+
} catch {
|
|
152
|
+
print("[CameraPreview] Could not configure focus for \(camera.localizedName): \(error)")
|
|
153
|
+
}
|
|
154
|
+
}
|
|
80
155
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
156
|
+
private func prepareOutputs() {
|
|
157
|
+
// Pre-create photo output with optimized settings
|
|
158
|
+
self.photoOutput = AVCapturePhotoOutput()
|
|
159
|
+
self.photoOutput?.isHighResolutionCaptureEnabled = false // Start with lower resolution for speed
|
|
160
|
+
|
|
161
|
+
// Configure photo output for better performance
|
|
162
|
+
if #available(iOS 13.0, *) {
|
|
163
|
+
self.photoOutput?.maxPhotoQualityPrioritization = .speed // Prioritize speed over quality initially
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Pre-create video output
|
|
167
|
+
self.fileVideoOutput = AVCaptureMovieFileOutput()
|
|
168
|
+
|
|
169
|
+
// Pre-create data output with optimized settings
|
|
170
|
+
self.dataOutput = AVCaptureVideoDataOutput()
|
|
171
|
+
self.dataOutput?.videoSettings = [
|
|
172
|
+
(kCVPixelBufferPixelFormatTypeKey as String): NSNumber(value: kCVPixelFormatType_32BGRA as UInt32)
|
|
173
|
+
]
|
|
174
|
+
self.dataOutput?.alwaysDiscardsLateVideoFrames = true
|
|
175
|
+
|
|
176
|
+
// Use a background queue for sample buffer processing to avoid blocking main thread
|
|
177
|
+
let dataOutputQueue = DispatchQueue(label: "camera.data.output", qos: .userInitiated)
|
|
178
|
+
self.dataOutput?.setSampleBufferDelegate(nil, queue: dataOutputQueue) // Will be set later
|
|
179
|
+
|
|
180
|
+
// Mark outputs as prepared
|
|
181
|
+
self.outputsPrepared = true
|
|
182
|
+
|
|
183
|
+
print("[CameraPreview] Outputs pre-created with performance optimizations")
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private func waitForOutputsToBeReady() {
|
|
187
|
+
// If outputs are already prepared, return immediately
|
|
188
|
+
if outputsPrepared {
|
|
189
|
+
return
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Wait for outputs to be prepared with a timeout
|
|
193
|
+
let semaphore = DispatchSemaphore(value: 0)
|
|
194
|
+
var outputsReady = false
|
|
195
|
+
|
|
196
|
+
// Check for outputs readiness periodically
|
|
197
|
+
let timer = Timer.scheduledTimer(withTimeInterval: 0.01, repeats: true) { timer in
|
|
198
|
+
if self.outputsPrepared {
|
|
199
|
+
outputsReady = true
|
|
200
|
+
timer.invalidate()
|
|
201
|
+
semaphore.signal()
|
|
84
202
|
}
|
|
203
|
+
}
|
|
85
204
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
let bestRearVirtualDevice = rearVirtualDevices.max { $0.constituentDevices.count < $1.constituentDevices.count }
|
|
205
|
+
// Wait for outputs to be ready or timeout after 2 seconds
|
|
206
|
+
let timeout = DispatchTime.now() + .seconds(2)
|
|
207
|
+
let result = semaphore.wait(timeout: timeout)
|
|
90
208
|
|
|
91
|
-
|
|
209
|
+
timer.invalidate()
|
|
92
210
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
self.rearCamera = firstRearCamera
|
|
99
|
-
print("[CameraPreview] WARN: No virtual rear camera found. Selected first available: \(firstRearCamera.localizedName)")
|
|
211
|
+
if result == .timedOut && !outputsReady {
|
|
212
|
+
print("[CameraPreview] Warning: Timed out waiting for outputs to be prepared, proceeding anyway")
|
|
213
|
+
// Fallback: prepare outputs synchronously if async preparation failed
|
|
214
|
+
if !outputsPrepared {
|
|
215
|
+
prepareOutputs()
|
|
100
216
|
}
|
|
101
|
-
|
|
217
|
+
} else {
|
|
218
|
+
print("[CameraPreview] Outputs ready, proceeding with camera preparation")
|
|
219
|
+
}
|
|
220
|
+
}
|
|
102
221
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
222
|
+
func upgradeQualitySettings() {
|
|
223
|
+
guard let captureSession = self.captureSession else { return }
|
|
224
|
+
|
|
225
|
+
// Upgrade session preset to high quality after initial startup
|
|
226
|
+
DispatchQueue.global(qos: .utility).async { [weak self] in
|
|
227
|
+
guard let self = self else { return }
|
|
228
|
+
|
|
229
|
+
captureSession.beginConfiguration()
|
|
230
|
+
|
|
231
|
+
// Upgrade to high quality preset
|
|
232
|
+
if captureSession.canSetSessionPreset(.high) && captureSession.sessionPreset != .high {
|
|
233
|
+
captureSession.sessionPreset = .high
|
|
234
|
+
print("[CameraPreview] Upgraded session preset to high quality")
|
|
113
235
|
}
|
|
114
236
|
|
|
115
|
-
|
|
116
|
-
|
|
237
|
+
// Upgrade photo output quality
|
|
238
|
+
if let photoOutput = self.photoOutput {
|
|
239
|
+
photoOutput.isHighResolutionCaptureEnabled = true
|
|
240
|
+
if #available(iOS 13.0, *) {
|
|
241
|
+
photoOutput.maxPhotoQualityPrioritization = .quality
|
|
242
|
+
}
|
|
243
|
+
print("[CameraPreview] Upgraded photo output to high resolution")
|
|
117
244
|
}
|
|
245
|
+
|
|
246
|
+
captureSession.commitConfiguration()
|
|
118
247
|
}
|
|
248
|
+
}
|
|
119
249
|
|
|
120
|
-
|
|
121
|
-
|
|
250
|
+
func prepare(cameraPosition: String, deviceId: String? = nil, disableAudio: Bool, cameraMode: Bool, aspectRatio: String? = nil, initialZoomLevel: Float = 1.0, completionHandler: @escaping (Error?) -> Void) {
|
|
251
|
+
// Use background queue for preparation to avoid blocking main thread
|
|
252
|
+
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
|
253
|
+
guard let self = self else {
|
|
254
|
+
DispatchQueue.main.async {
|
|
255
|
+
completionHandler(CameraControllerError.unknown)
|
|
256
|
+
}
|
|
257
|
+
return
|
|
258
|
+
}
|
|
122
259
|
|
|
123
|
-
|
|
260
|
+
do {
|
|
261
|
+
// Session and outputs already created in load(), just configure user-specific settings
|
|
262
|
+
if self.captureSession == nil {
|
|
263
|
+
// Fallback if prepareFullSession() wasn't called
|
|
264
|
+
self.prepareFullSession()
|
|
265
|
+
}
|
|
124
266
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
deviceTypes: [.builtInWideAngleCamera, .builtInUltraWideCamera, .builtInTelephotoCamera, .builtInDualCamera, .builtInDualWideCamera, .builtInTripleCamera, .builtInTrueDepthCamera],
|
|
129
|
-
mediaType: .video,
|
|
130
|
-
position: .unspecified
|
|
131
|
-
).devices
|
|
267
|
+
guard let captureSession = self.captureSession else {
|
|
268
|
+
throw CameraControllerError.captureSessionIsMissing
|
|
269
|
+
}
|
|
132
270
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
271
|
+
print("[CameraPreview] Fast prepare - using pre-initialized session")
|
|
272
|
+
|
|
273
|
+
// Ensure outputs are prepared synchronously before starting session
|
|
274
|
+
self.prepareOutputs()
|
|
275
|
+
self.waitForOutputsToBeReady()
|
|
276
|
+
|
|
277
|
+
// Configure device inputs for the requested camera
|
|
278
|
+
try self.configureDeviceInputs(cameraPosition: cameraPosition, deviceId: deviceId, disableAudio: disableAudio)
|
|
279
|
+
|
|
280
|
+
// Add data output early to detect first frame
|
|
281
|
+
captureSession.beginConfiguration()
|
|
282
|
+
if let dataOutput = self.dataOutput, captureSession.canAddOutput(dataOutput) {
|
|
283
|
+
captureSession.addOutput(dataOutput)
|
|
284
|
+
// Set delegate to detect first frame
|
|
285
|
+
dataOutput.setSampleBufferDelegate(self, queue: DispatchQueue.main)
|
|
136
286
|
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
287
|
+
captureSession.commitConfiguration()
|
|
288
|
+
|
|
289
|
+
// Reset first frame detection
|
|
290
|
+
self.hasReceivedFirstFrame = false
|
|
291
|
+
|
|
292
|
+
// Start the session on background thread (AVCaptureSession.startRunning() is thread-safe)
|
|
293
|
+
captureSession.startRunning()
|
|
294
|
+
print("[CameraPreview] Session started")
|
|
295
|
+
|
|
296
|
+
// Validate and set initial zoom level asynchronously
|
|
297
|
+
if initialZoomLevel != 1.0 {
|
|
298
|
+
DispatchQueue.main.async { [weak self] in
|
|
299
|
+
self?.setInitialZoom(level: initialZoomLevel)
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Call completion on main thread
|
|
304
|
+
DispatchQueue.main.async {
|
|
305
|
+
completionHandler(nil)
|
|
306
|
+
|
|
307
|
+
// Upgrade quality settings after a short delay for better user experience
|
|
308
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
|
309
|
+
guard let self = self else { return }
|
|
310
|
+
|
|
311
|
+
// Add remaining outputs to session and apply user settings
|
|
312
|
+
do {
|
|
313
|
+
try self.addRemainingOutputsToSession(cameraMode: cameraMode, aspectRatio: aspectRatio)
|
|
314
|
+
print("[CameraPreview] Remaining outputs successfully added to session")
|
|
315
|
+
} catch {
|
|
316
|
+
print("[CameraPreview] Error adding outputs to session: \(error)")
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
self.upgradeQualitySettings()
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
} catch {
|
|
323
|
+
DispatchQueue.main.async {
|
|
324
|
+
completionHandler(error)
|
|
143
325
|
}
|
|
144
326
|
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
private func setInitialZoom(level: Float) {
|
|
331
|
+
let device = (currentCameraPosition == .rear) ? rearCamera : frontCamera
|
|
332
|
+
guard let device = device else { return }
|
|
145
333
|
|
|
146
|
-
|
|
147
|
-
|
|
334
|
+
let minZoom = device.minAvailableVideoZoomFactor
|
|
335
|
+
let maxZoom = min(device.maxAvailableVideoZoomFactor, saneMaxZoomFactor)
|
|
336
|
+
|
|
337
|
+
guard CGFloat(level) >= minZoom && CGFloat(level) <= maxZoom else {
|
|
338
|
+
print("[CameraPreview] Initial zoom level \(level) out of range (\(minZoom)-\(maxZoom))")
|
|
339
|
+
return
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
do {
|
|
343
|
+
try device.lockForConfiguration()
|
|
344
|
+
device.videoZoomFactor = CGFloat(level)
|
|
345
|
+
device.unlockForConfiguration()
|
|
346
|
+
self.zoomFactor = CGFloat(level)
|
|
347
|
+
print("[CameraPreview] Set initial zoom to \(level)")
|
|
348
|
+
} catch {
|
|
349
|
+
print("[CameraPreview] Failed to set initial zoom: \(error)")
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
private func configureDeviceInputs(cameraPosition: String, deviceId: String?, disableAudio: Bool) throws {
|
|
354
|
+
guard let captureSession = self.captureSession else { throw CameraControllerError.captureSessionIsMissing }
|
|
355
|
+
|
|
356
|
+
// Ensure cameras are discovered before configuring inputs
|
|
357
|
+
ensureCamerasDiscovered()
|
|
358
|
+
|
|
359
|
+
var selectedDevice: AVCaptureDevice?
|
|
360
|
+
|
|
361
|
+
// If deviceId is specified, find that specific device from discovered devices
|
|
362
|
+
if let deviceId = deviceId {
|
|
363
|
+
selectedDevice = self.allDiscoveredDevices.first(where: { $0.uniqueID == deviceId })
|
|
364
|
+
guard selectedDevice != nil else {
|
|
365
|
+
print("[CameraPreview] ERROR: Device with ID \(deviceId) not found in discovered devices")
|
|
148
366
|
throw CameraControllerError.noCamerasAvailable
|
|
149
367
|
}
|
|
368
|
+
} else {
|
|
369
|
+
// Use position-based selection from discovered cameras
|
|
370
|
+
if cameraPosition == "rear" {
|
|
371
|
+
selectedDevice = self.rearCamera
|
|
372
|
+
} else if cameraPosition == "front" {
|
|
373
|
+
selectedDevice = self.frontCamera
|
|
374
|
+
}
|
|
375
|
+
}
|
|
150
376
|
|
|
151
|
-
|
|
152
|
-
|
|
377
|
+
guard let finalDevice = selectedDevice else {
|
|
378
|
+
print("[CameraPreview] ERROR: No camera device selected for position: \(cameraPosition)")
|
|
379
|
+
throw CameraControllerError.noCamerasAvailable
|
|
380
|
+
}
|
|
153
381
|
|
|
154
|
-
|
|
155
|
-
|
|
382
|
+
print("[CameraPreview] Configuring device: \(finalDevice.localizedName)")
|
|
383
|
+
let deviceInput = try AVCaptureDeviceInput(device: finalDevice)
|
|
156
384
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
self.frontCamera = finalDevice
|
|
160
|
-
self.currentCameraPosition = .front
|
|
161
|
-
} else {
|
|
162
|
-
self.rearCameraInput = deviceInput
|
|
163
|
-
self.rearCamera = finalDevice
|
|
164
|
-
self.currentCameraPosition = .rear
|
|
385
|
+
if captureSession.canAddInput(deviceInput) {
|
|
386
|
+
captureSession.addInput(deviceInput)
|
|
165
387
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
388
|
+
if finalDevice.position == .front {
|
|
389
|
+
self.frontCameraInput = deviceInput
|
|
390
|
+
self.currentCameraPosition = .front
|
|
391
|
+
} else {
|
|
392
|
+
self.rearCameraInput = deviceInput
|
|
393
|
+
self.currentCameraPosition = .rear
|
|
171
394
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
finalDevice.videoZoomFactor = defaultWideAngleZoom
|
|
180
|
-
}
|
|
395
|
+
// Configure zoom for multi-camera systems - simplified and faster
|
|
396
|
+
if finalDevice.isVirtualDevice && finalDevice.constituentDevices.count > 1 {
|
|
397
|
+
try finalDevice.lockForConfiguration()
|
|
398
|
+
let defaultWideAngleZoom: CGFloat = 1.0 // Changed from 2.0 to 1.0 for faster startup
|
|
399
|
+
if defaultWideAngleZoom >= finalDevice.minAvailableVideoZoomFactor && defaultWideAngleZoom <= finalDevice.maxAvailableVideoZoomFactor {
|
|
400
|
+
print("[CameraPreview] Setting initial zoom to \(defaultWideAngleZoom)")
|
|
401
|
+
finalDevice.videoZoomFactor = defaultWideAngleZoom
|
|
181
402
|
}
|
|
182
403
|
finalDevice.unlockForConfiguration()
|
|
183
|
-
// --- End of Correction ---
|
|
184
404
|
}
|
|
185
|
-
} else {
|
|
186
|
-
print("[CameraPreview] ERROR: Cannot add device input to session.")
|
|
187
|
-
throw CameraControllerError.inputsAreInvalid
|
|
188
405
|
}
|
|
406
|
+
} else {
|
|
407
|
+
throw CameraControllerError.inputsAreInvalid
|
|
408
|
+
}
|
|
189
409
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
410
|
+
// Add audio input if needed
|
|
411
|
+
if !disableAudio {
|
|
412
|
+
if self.audioDevice == nil {
|
|
413
|
+
self.audioDevice = AVCaptureDevice.default(for: AVMediaType.audio)
|
|
414
|
+
}
|
|
415
|
+
if let audioDevice = self.audioDevice {
|
|
416
|
+
self.audioInput = try AVCaptureDeviceInput(device: audioDevice)
|
|
417
|
+
if captureSession.canAddInput(self.audioInput!) {
|
|
418
|
+
captureSession.addInput(self.audioInput!)
|
|
419
|
+
} else {
|
|
420
|
+
throw CameraControllerError.inputsAreInvalid
|
|
199
421
|
}
|
|
200
422
|
}
|
|
201
423
|
}
|
|
424
|
+
}
|
|
202
425
|
|
|
203
|
-
func
|
|
204
|
-
|
|
426
|
+
private func addRemainingOutputsToSession(cameraMode: Bool, aspectRatio: String?) throws {
|
|
427
|
+
guard let captureSession = self.captureSession else { throw CameraControllerError.captureSessionIsMissing }
|
|
205
428
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
} else if cameraMode && self.highResolutionOutput && captureSession.canSetSessionPreset(.high) {
|
|
210
|
-
captureSession.sessionPreset = .high
|
|
211
|
-
}
|
|
429
|
+
// Begin configuration to batch all changes
|
|
430
|
+
captureSession.beginConfiguration()
|
|
431
|
+
defer { captureSession.commitConfiguration() }
|
|
212
432
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
self.photoOutput?.isHighResolutionCaptureEnabled = self.highResolutionOutput
|
|
216
|
-
if captureSession.canAddOutput(self.photoOutput!) { captureSession.addOutput(self.photoOutput!) }
|
|
433
|
+
// Update session preset based on aspect ratio if needed
|
|
434
|
+
var targetPreset: AVCaptureSession.Preset = .high // Default to high quality
|
|
217
435
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
436
|
+
if let aspectRatio = aspectRatio {
|
|
437
|
+
switch aspectRatio {
|
|
438
|
+
case "16:9":
|
|
439
|
+
targetPreset = captureSession.canSetSessionPreset(.hd1920x1080) ? .hd1920x1080 : .high
|
|
440
|
+
case "4:3":
|
|
441
|
+
targetPreset = captureSession.canSetSessionPreset(.photo) ? .photo : .high
|
|
442
|
+
default:
|
|
443
|
+
targetPreset = .high
|
|
222
444
|
}
|
|
223
|
-
captureSession.startRunning()
|
|
224
445
|
}
|
|
225
446
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
captureSession.addOutput(self.dataOutput!)
|
|
236
|
-
}
|
|
447
|
+
// Always try to set the best preset available
|
|
448
|
+
if captureSession.canSetSessionPreset(targetPreset) {
|
|
449
|
+
captureSession.sessionPreset = targetPreset
|
|
450
|
+
print("[CameraPreview] Updated preset to \(targetPreset) for aspect ratio: \(aspectRatio ?? "default")")
|
|
451
|
+
} else if captureSession.canSetSessionPreset(.high) {
|
|
452
|
+
// Fallback to high if target preset not available
|
|
453
|
+
captureSession.sessionPreset = .high
|
|
454
|
+
print("[CameraPreview] Fallback to high preset")
|
|
455
|
+
}
|
|
237
456
|
|
|
238
|
-
|
|
457
|
+
// Add photo output (already created in prepareOutputs)
|
|
458
|
+
if let photoOutput = self.photoOutput, captureSession.canAddOutput(photoOutput) {
|
|
459
|
+
photoOutput.isHighResolutionCaptureEnabled = true
|
|
460
|
+
captureSession.addOutput(photoOutput)
|
|
461
|
+
}
|
|
239
462
|
|
|
240
|
-
|
|
241
|
-
|
|
463
|
+
// Add video output only if camera mode is enabled
|
|
464
|
+
if cameraMode, let videoOutput = self.fileVideoOutput, captureSession.canAddOutput(videoOutput) {
|
|
465
|
+
captureSession.addOutput(videoOutput)
|
|
242
466
|
}
|
|
467
|
+
// Data output was already added in prepare() to detect first frame
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
private func addOutputsToSession(cameraMode: Bool, aspectRatio: String?) throws {
|
|
471
|
+
guard let captureSession = self.captureSession else { throw CameraControllerError.captureSessionIsMissing }
|
|
243
472
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
try configureCaptureDevices()
|
|
248
|
-
try configureDeviceInputs()
|
|
249
|
-
try configurePhotoOutput(cameraMode: cameraMode)
|
|
250
|
-
try configureDataOutput()
|
|
251
|
-
// try configureVideoOutput()
|
|
252
|
-
} catch {
|
|
253
|
-
DispatchQueue.main.async {
|
|
254
|
-
completionHandler(error)
|
|
255
|
-
}
|
|
473
|
+
// Begin configuration to batch all changes
|
|
474
|
+
captureSession.beginConfiguration()
|
|
475
|
+
defer { captureSession.commitConfiguration() }
|
|
256
476
|
|
|
257
|
-
|
|
477
|
+
// Update session preset based on aspect ratio if needed
|
|
478
|
+
var targetPreset: AVCaptureSession.Preset = .high // Default to high quality
|
|
479
|
+
|
|
480
|
+
if let aspectRatio = aspectRatio {
|
|
481
|
+
switch aspectRatio {
|
|
482
|
+
case "16:9":
|
|
483
|
+
targetPreset = captureSession.canSetSessionPreset(.hd1920x1080) ? .hd1920x1080 : .high
|
|
484
|
+
case "4:3":
|
|
485
|
+
targetPreset = captureSession.canSetSessionPreset(.photo) ? .photo : .high
|
|
486
|
+
default:
|
|
487
|
+
targetPreset = .high
|
|
258
488
|
}
|
|
489
|
+
}
|
|
259
490
|
|
|
491
|
+
// Always try to set the best preset available
|
|
492
|
+
if captureSession.canSetSessionPreset(targetPreset) {
|
|
493
|
+
captureSession.sessionPreset = targetPreset
|
|
494
|
+
print("[CameraPreview] Updated preset to \(targetPreset) for aspect ratio: \(aspectRatio ?? "default")")
|
|
495
|
+
} else if captureSession.canSetSessionPreset(.high) {
|
|
496
|
+
// Fallback to high if target preset not available
|
|
497
|
+
captureSession.sessionPreset = .high
|
|
498
|
+
print("[CameraPreview] Fallback to high preset")
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Add photo output (already created in prepareOutputs)
|
|
502
|
+
if let photoOutput = self.photoOutput, captureSession.canAddOutput(photoOutput) {
|
|
503
|
+
photoOutput.isHighResolutionCaptureEnabled = true
|
|
504
|
+
captureSession.addOutput(photoOutput)
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// Add video output only if camera mode is enabled
|
|
508
|
+
if cameraMode, let videoOutput = self.fileVideoOutput, captureSession.canAddOutput(videoOutput) {
|
|
509
|
+
captureSession.addOutput(videoOutput)
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// Add data output
|
|
513
|
+
if let dataOutput = self.dataOutput, captureSession.canAddOutput(dataOutput) {
|
|
514
|
+
captureSession.addOutput(dataOutput)
|
|
515
|
+
// Set delegate after outputs are added for better performance
|
|
260
516
|
DispatchQueue.main.async {
|
|
261
|
-
|
|
517
|
+
dataOutput.setSampleBufferDelegate(self, queue: DispatchQueue.main)
|
|
262
518
|
}
|
|
263
519
|
}
|
|
264
520
|
}
|
|
@@ -266,13 +522,51 @@ extension CameraController {
|
|
|
266
522
|
func displayPreview(on view: UIView) throws {
|
|
267
523
|
guard let captureSession = self.captureSession, captureSession.isRunning else { throw CameraControllerError.captureSessionIsMissing }
|
|
268
524
|
|
|
269
|
-
|
|
270
|
-
|
|
525
|
+
print("[CameraPreview] displayPreview called with view frame: \(view.frame)")
|
|
526
|
+
|
|
527
|
+
// Create and configure preview layer in one go
|
|
528
|
+
let previewLayer = AVCaptureVideoPreviewLayer(session: captureSession)
|
|
529
|
+
|
|
530
|
+
// Batch all layer configuration to avoid multiple redraws
|
|
531
|
+
CATransaction.begin()
|
|
532
|
+
CATransaction.setDisableActions(true)
|
|
533
|
+
|
|
534
|
+
previewLayer.videoGravity = AVLayerVideoGravity.resizeAspectFill
|
|
535
|
+
previewLayer.connection?.videoOrientation = .portrait
|
|
536
|
+
previewLayer.isOpaque = true
|
|
537
|
+
previewLayer.contentsScale = UIScreen.main.scale
|
|
538
|
+
previewLayer.frame = view.bounds
|
|
539
|
+
|
|
540
|
+
// Insert layer and store reference
|
|
541
|
+
view.layer.insertSublayer(previewLayer, at: 0)
|
|
542
|
+
self.previewLayer = previewLayer
|
|
543
|
+
|
|
544
|
+
CATransaction.commit()
|
|
545
|
+
|
|
546
|
+
print("[CameraPreview] Set preview layer frame to view bounds: \(view.bounds)")
|
|
547
|
+
print("[CameraPreview] Session preset: \(captureSession.sessionPreset.rawValue)")
|
|
548
|
+
|
|
549
|
+
// Update video orientation asynchronously to avoid blocking
|
|
550
|
+
DispatchQueue.main.async { [weak self] in
|
|
551
|
+
self?.updateVideoOrientation()
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
func addGridOverlay(to view: UIView, gridMode: String) {
|
|
556
|
+
removeGridOverlay()
|
|
271
557
|
|
|
272
|
-
|
|
273
|
-
|
|
558
|
+
// Disable animation for grid overlay creation and positioning
|
|
559
|
+
CATransaction.begin()
|
|
560
|
+
CATransaction.setDisableActions(true)
|
|
561
|
+
gridOverlayView = GridOverlayView(frame: view.bounds)
|
|
562
|
+
gridOverlayView?.gridMode = gridMode
|
|
563
|
+
view.addSubview(gridOverlayView!)
|
|
564
|
+
CATransaction.commit()
|
|
565
|
+
}
|
|
274
566
|
|
|
275
|
-
|
|
567
|
+
func removeGridOverlay() {
|
|
568
|
+
gridOverlayView?.removeFromSuperview()
|
|
569
|
+
gridOverlayView = nil
|
|
276
570
|
}
|
|
277
571
|
|
|
278
572
|
func setupGestures(target: UIView, enableZoom: Bool) {
|
|
@@ -291,6 +585,10 @@ extension CameraController {
|
|
|
291
585
|
func setupPinchGesture(target: UIView, selector: Selector, delegate: UIGestureRecognizerDelegate?) {
|
|
292
586
|
let pinchGesture = UIPinchGestureRecognizer(target: self, action: selector)
|
|
293
587
|
pinchGesture.delegate = delegate
|
|
588
|
+
// Optimize gesture recognition for better performance
|
|
589
|
+
pinchGesture.delaysTouchesBegan = false
|
|
590
|
+
pinchGesture.delaysTouchesEnded = false
|
|
591
|
+
pinchGesture.cancelsTouchesInView = false
|
|
294
592
|
target.addGestureRecognizer(pinchGesture)
|
|
295
593
|
}
|
|
296
594
|
|
|
@@ -298,8 +596,8 @@ extension CameraController {
|
|
|
298
596
|
if Thread.isMainThread {
|
|
299
597
|
updateVideoOrientationOnMainThread()
|
|
300
598
|
} else {
|
|
301
|
-
DispatchQueue.main.
|
|
302
|
-
self
|
|
599
|
+
DispatchQueue.main.sync {
|
|
600
|
+
self.updateVideoOrientationOnMainThread()
|
|
303
601
|
}
|
|
304
602
|
}
|
|
305
603
|
}
|
|
@@ -340,7 +638,7 @@ extension CameraController {
|
|
|
340
638
|
|
|
341
639
|
// Ensure we have the necessary cameras
|
|
342
640
|
guard (currentCameraPosition == .front && rearCamera != nil) ||
|
|
343
|
-
|
|
641
|
+
(currentCameraPosition == .rear && frontCamera != nil) else {
|
|
344
642
|
throw CameraControllerError.noCamerasAvailable
|
|
345
643
|
}
|
|
346
644
|
|
|
@@ -356,9 +654,7 @@ extension CameraController {
|
|
|
356
654
|
captureSession.commitConfiguration()
|
|
357
655
|
// Restart the session if it was running before
|
|
358
656
|
if wasRunning {
|
|
359
|
-
|
|
360
|
-
self?.captureSession?.startRunning()
|
|
361
|
-
}
|
|
657
|
+
captureSession.startRunning()
|
|
362
658
|
}
|
|
363
659
|
}
|
|
364
660
|
|
|
@@ -368,7 +664,7 @@ extension CameraController {
|
|
|
368
664
|
// Remove only video inputs
|
|
369
665
|
captureSession.inputs.forEach { input in
|
|
370
666
|
if (input as? AVCaptureDeviceInput)?.device.hasMediaType(.video) ?? false {
|
|
371
|
-
|
|
667
|
+
captureSession.removeInput(input)
|
|
372
668
|
}
|
|
373
669
|
}
|
|
374
670
|
|
|
@@ -387,7 +683,7 @@ extension CameraController {
|
|
|
387
683
|
rearCamera.unlockForConfiguration()
|
|
388
684
|
|
|
389
685
|
if let newInput = try? AVCaptureDeviceInput(device: rearCamera),
|
|
390
|
-
|
|
686
|
+
captureSession.canAddInput(newInput) {
|
|
391
687
|
captureSession.addInput(newInput)
|
|
392
688
|
rearCameraInput = newInput
|
|
393
689
|
self.currentCameraPosition = .rear
|
|
@@ -407,7 +703,7 @@ extension CameraController {
|
|
|
407
703
|
frontCamera.unlockForConfiguration()
|
|
408
704
|
|
|
409
705
|
if let newInput = try? AVCaptureDeviceInput(device: frontCamera),
|
|
410
|
-
|
|
706
|
+
captureSession.canAddInput(newInput) {
|
|
411
707
|
captureSession.addInput(newInput)
|
|
412
708
|
frontCameraInput = newInput
|
|
413
709
|
self.currentCameraPosition = .front
|
|
@@ -422,20 +718,186 @@ extension CameraController {
|
|
|
422
718
|
}
|
|
423
719
|
|
|
424
720
|
// Update video orientation
|
|
425
|
-
|
|
426
|
-
self?.updateVideoOrientation()
|
|
427
|
-
}
|
|
721
|
+
self.updateVideoOrientation()
|
|
428
722
|
}
|
|
429
723
|
|
|
430
|
-
|
|
431
|
-
guard let
|
|
724
|
+
func captureImage(width: Int?, height: Int?, aspectRatio: String?, quality: Float, gpsLocation: CLLocation?, completion: @escaping (UIImage?, Error?) -> Void) {
|
|
725
|
+
guard let photoOutput = self.photoOutput else {
|
|
726
|
+
completion(nil, NSError(domain: "Camera", code: 0, userInfo: [NSLocalizedDescriptionKey: "Photo output is not available"]))
|
|
727
|
+
return
|
|
728
|
+
}
|
|
729
|
+
|
|
432
730
|
let settings = AVCapturePhotoSettings()
|
|
433
731
|
|
|
434
|
-
|
|
435
|
-
|
|
732
|
+
// Apply the current flash mode to the photo settings
|
|
733
|
+
// Check if the current device supports flash
|
|
734
|
+
var currentCamera: AVCaptureDevice?
|
|
735
|
+
switch currentCameraPosition {
|
|
736
|
+
case .front:
|
|
737
|
+
currentCamera = self.frontCamera
|
|
738
|
+
case .rear:
|
|
739
|
+
currentCamera = self.rearCamera
|
|
740
|
+
default:
|
|
741
|
+
break
|
|
742
|
+
}
|
|
436
743
|
|
|
437
|
-
|
|
438
|
-
|
|
744
|
+
// Only apply flash if the device has flash and the flash mode is supported
|
|
745
|
+
if let device = currentCamera, device.hasFlash {
|
|
746
|
+
let supportedFlashModes = photoOutput.supportedFlashModes
|
|
747
|
+
if supportedFlashModes.contains(self.flashMode) {
|
|
748
|
+
settings.flashMode = self.flashMode
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
self.photoCaptureCompletionBlock = { (image, error) in
|
|
753
|
+
if let error = error {
|
|
754
|
+
completion(nil, error)
|
|
755
|
+
return
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
guard let image = image else {
|
|
759
|
+
completion(nil, NSError(domain: "Camera", code: 0, userInfo: [NSLocalizedDescriptionKey: "Failed to capture image"]))
|
|
760
|
+
return
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
if let location = gpsLocation {
|
|
764
|
+
self.addGPSMetadata(to: image, location: location)
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
var finalImage = image
|
|
768
|
+
|
|
769
|
+
// When no dimensions are specified, crop to match what's visible in preview
|
|
770
|
+
if width == nil && height == nil {
|
|
771
|
+
// If aspectRatio is specified, use it to crop
|
|
772
|
+
if let aspectRatio = aspectRatio {
|
|
773
|
+
let components = aspectRatio.split(separator: ":").compactMap { Double($0) }
|
|
774
|
+
if components.count == 2 {
|
|
775
|
+
// For capture in portrait orientation, swap the aspect ratio (16:9 becomes 9:16)
|
|
776
|
+
let isPortrait = image.size.height > image.size.width
|
|
777
|
+
let targetAspectRatio = isPortrait ? components[1] / components[0] : components[0] / components[1]
|
|
778
|
+
let imageSize = image.size
|
|
779
|
+
let originalAspectRatio = imageSize.width / imageSize.height
|
|
780
|
+
|
|
781
|
+
var targetSize = imageSize
|
|
782
|
+
|
|
783
|
+
if originalAspectRatio > targetAspectRatio {
|
|
784
|
+
// Original is wider than target - fit by height
|
|
785
|
+
targetSize.width = imageSize.height * CGFloat(targetAspectRatio)
|
|
786
|
+
} else {
|
|
787
|
+
// Original is taller than target - fit by width
|
|
788
|
+
targetSize.height = imageSize.width / CGFloat(targetAspectRatio)
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// Center crop the image
|
|
792
|
+
if let croppedImage = self.cropImageToAspectRatio(image: image, targetSize: targetSize) {
|
|
793
|
+
finalImage = croppedImage
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
} else {
|
|
797
|
+
// No aspectRatio specified but we need to crop to match preview
|
|
798
|
+
// Since preview uses resizeAspectFill, we need to crop the captured image
|
|
799
|
+
// to match what's visible in the preview
|
|
800
|
+
if let previewLayer = self.previewLayer,
|
|
801
|
+
let croppedImage = self.cropImageToMatchPreview(image: image, previewLayer: previewLayer) {
|
|
802
|
+
finalImage = croppedImage
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
} else if let width = width, let height = height {
|
|
806
|
+
finalImage = self.resizeImage(image: image, to: CGSize(width: width, height: height))!
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
completion(finalImage, nil)
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
photoOutput.capturePhoto(with: settings, delegate: self)
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
func addGPSMetadata(to image: UIImage, location: CLLocation) {
|
|
816
|
+
guard let jpegData = image.jpegData(compressionQuality: 1.0),
|
|
817
|
+
let source = CGImageSourceCreateWithData(jpegData as CFData, nil),
|
|
818
|
+
let uti = CGImageSourceGetType(source) else { return }
|
|
819
|
+
|
|
820
|
+
var metadata = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [String: Any] ?? [:]
|
|
821
|
+
|
|
822
|
+
let formatter = DateFormatter()
|
|
823
|
+
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ"
|
|
824
|
+
formatter.timeZone = TimeZone(abbreviation: "UTC")
|
|
825
|
+
|
|
826
|
+
let gpsDict: [String: Any] = [
|
|
827
|
+
kCGImagePropertyGPSLatitude as String: abs(location.coordinate.latitude),
|
|
828
|
+
kCGImagePropertyGPSLatitudeRef as String: location.coordinate.latitude >= 0 ? "N" : "S",
|
|
829
|
+
kCGImagePropertyGPSLongitude as String: abs(location.coordinate.longitude),
|
|
830
|
+
kCGImagePropertyGPSLongitudeRef as String: location.coordinate.longitude >= 0 ? "E" : "W",
|
|
831
|
+
kCGImagePropertyGPSTimeStamp as String: formatter.string(from: location.timestamp),
|
|
832
|
+
kCGImagePropertyGPSAltitude as String: location.altitude,
|
|
833
|
+
kCGImagePropertyGPSAltitudeRef as String: location.altitude >= 0 ? 0 : 1
|
|
834
|
+
]
|
|
835
|
+
|
|
836
|
+
metadata[kCGImagePropertyGPSDictionary as String] = gpsDict
|
|
837
|
+
|
|
838
|
+
let destData = NSMutableData()
|
|
839
|
+
guard let destination = CGImageDestinationCreateWithData(destData, uti, 1, nil) else { return }
|
|
840
|
+
CGImageDestinationAddImageFromSource(destination, source, 0, metadata as CFDictionary)
|
|
841
|
+
CGImageDestinationFinalize(destination)
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
func resizeImage(image: UIImage, to size: CGSize) -> UIImage? {
|
|
845
|
+
let renderer = UIGraphicsImageRenderer(size: size)
|
|
846
|
+
let resizedImage = renderer.image { (_) in
|
|
847
|
+
image.draw(in: CGRect(origin: .zero, size: size))
|
|
848
|
+
}
|
|
849
|
+
return resizedImage
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
func cropImageToAspectRatio(image: UIImage, targetSize: CGSize) -> UIImage? {
|
|
853
|
+
let imageSize = image.size
|
|
854
|
+
|
|
855
|
+
// Calculate the crop rect - center crop
|
|
856
|
+
let xOffset = (imageSize.width - targetSize.width) / 2
|
|
857
|
+
let yOffset = (imageSize.height - targetSize.height) / 2
|
|
858
|
+
let cropRect = CGRect(x: xOffset, y: yOffset, width: targetSize.width, height: targetSize.height)
|
|
859
|
+
|
|
860
|
+
// Create the cropped image
|
|
861
|
+
guard let cgImage = image.cgImage,
|
|
862
|
+
let croppedCGImage = cgImage.cropping(to: cropRect) else {
|
|
863
|
+
return nil
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
return UIImage(cgImage: croppedCGImage, scale: image.scale, orientation: image.imageOrientation)
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
func cropImageToMatchPreview(image: UIImage, previewLayer: AVCaptureVideoPreviewLayer) -> UIImage? {
|
|
870
|
+
// Get the dimensions of the preview layer
|
|
871
|
+
let previewBounds = previewLayer.bounds
|
|
872
|
+
let previewAspectRatio = previewBounds.width / previewBounds.height
|
|
873
|
+
|
|
874
|
+
// Get the dimensions of the captured image
|
|
875
|
+
let imageSize = image.size
|
|
876
|
+
let imageAspectRatio = imageSize.width / imageSize.height
|
|
877
|
+
|
|
878
|
+
// Since we're using resizeAspectFill, we need to calculate what portion of the image
|
|
879
|
+
// is visible in the preview
|
|
880
|
+
var cropRect: CGRect
|
|
881
|
+
|
|
882
|
+
if imageAspectRatio > previewAspectRatio {
|
|
883
|
+
// Image is wider than preview - crop horizontally
|
|
884
|
+
let visibleWidth = imageSize.height * previewAspectRatio
|
|
885
|
+
let xOffset = (imageSize.width - visibleWidth) / 2
|
|
886
|
+
cropRect = CGRect(x: xOffset, y: 0, width: visibleWidth, height: imageSize.height)
|
|
887
|
+
} else {
|
|
888
|
+
// Image is taller than preview - crop vertically
|
|
889
|
+
let visibleHeight = imageSize.width / previewAspectRatio
|
|
890
|
+
let yOffset = (imageSize.height - visibleHeight) / 2
|
|
891
|
+
cropRect = CGRect(x: 0, y: yOffset, width: imageSize.width, height: visibleHeight)
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// Create the cropped image
|
|
895
|
+
guard let cgImage = image.cgImage,
|
|
896
|
+
let croppedCGImage = cgImage.cropping(to: cropRect) else {
|
|
897
|
+
return nil
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
return UIImage(cgImage: croppedCGImage, scale: image.scale, orientation: image.imageOrientation)
|
|
439
901
|
}
|
|
440
902
|
|
|
441
903
|
func captureSample(completion: @escaping (UIImage?, Error?) -> Void) {
|
|
@@ -612,7 +1074,7 @@ extension CameraController {
|
|
|
612
1074
|
)
|
|
613
1075
|
}
|
|
614
1076
|
|
|
615
|
-
func setZoom(level: CGFloat, ramp: Bool) throws {
|
|
1077
|
+
func setZoom(level: CGFloat, ramp: Bool, autoFocus: Bool = true) throws {
|
|
616
1078
|
var currentCamera: AVCaptureDevice?
|
|
617
1079
|
switch currentCameraPosition {
|
|
618
1080
|
case .front:
|
|
@@ -633,7 +1095,8 @@ extension CameraController {
|
|
|
633
1095
|
try device.lockForConfiguration()
|
|
634
1096
|
|
|
635
1097
|
if ramp {
|
|
636
|
-
|
|
1098
|
+
// Use a very fast ramp rate for immediate response
|
|
1099
|
+
device.ramp(toVideoZoomFactor: zoomLevel, withRate: 8.0)
|
|
637
1100
|
} else {
|
|
638
1101
|
device.videoZoomFactor = zoomLevel
|
|
639
1102
|
}
|
|
@@ -642,11 +1105,123 @@ extension CameraController {
|
|
|
642
1105
|
|
|
643
1106
|
// Update our internal zoom factor tracking
|
|
644
1107
|
self.zoomFactor = zoomLevel
|
|
1108
|
+
|
|
1109
|
+
// Trigger autofocus after zoom if requested
|
|
1110
|
+
if autoFocus {
|
|
1111
|
+
self.triggerAutoFocus()
|
|
1112
|
+
}
|
|
645
1113
|
} catch {
|
|
646
1114
|
throw CameraControllerError.invalidOperation
|
|
647
1115
|
}
|
|
648
1116
|
}
|
|
649
1117
|
|
|
1118
|
+
private func triggerAutoFocus() {
|
|
1119
|
+
var currentCamera: AVCaptureDevice?
|
|
1120
|
+
switch currentCameraPosition {
|
|
1121
|
+
case .front:
|
|
1122
|
+
currentCamera = self.frontCamera
|
|
1123
|
+
case .rear:
|
|
1124
|
+
currentCamera = self.rearCamera
|
|
1125
|
+
default: break
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
guard let device = currentCamera else {
|
|
1129
|
+
return
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
// Focus on the center of the preview (0.5, 0.5)
|
|
1133
|
+
let centerPoint = CGPoint(x: 0.5, y: 0.5)
|
|
1134
|
+
|
|
1135
|
+
do {
|
|
1136
|
+
try device.lockForConfiguration()
|
|
1137
|
+
|
|
1138
|
+
// Set focus mode to auto if supported
|
|
1139
|
+
if device.isFocusModeSupported(.autoFocus) {
|
|
1140
|
+
device.focusMode = .autoFocus
|
|
1141
|
+
if device.isFocusPointOfInterestSupported {
|
|
1142
|
+
device.focusPointOfInterest = centerPoint
|
|
1143
|
+
}
|
|
1144
|
+
} else if device.isFocusModeSupported(.continuousAutoFocus) {
|
|
1145
|
+
device.focusMode = .continuousAutoFocus
|
|
1146
|
+
if device.isFocusPointOfInterestSupported {
|
|
1147
|
+
device.focusPointOfInterest = centerPoint
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
// Also set exposure point if supported
|
|
1152
|
+
if device.isExposurePointOfInterestSupported && device.isExposureModeSupported(.autoExpose) {
|
|
1153
|
+
device.exposureMode = .autoExpose
|
|
1154
|
+
device.exposurePointOfInterest = centerPoint
|
|
1155
|
+
} else if device.isExposureModeSupported(.continuousAutoExposure) {
|
|
1156
|
+
device.exposureMode = .continuousAutoExposure
|
|
1157
|
+
if device.isExposurePointOfInterestSupported {
|
|
1158
|
+
device.exposurePointOfInterest = centerPoint
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
device.unlockForConfiguration()
|
|
1163
|
+
} catch {
|
|
1164
|
+
// Silently ignore errors during autofocus
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
func setFocus(at point: CGPoint, showIndicator: Bool = false, in view: UIView? = nil) throws {
|
|
1169
|
+
// Validate that coordinates are within bounds (0-1 range for device coordinates)
|
|
1170
|
+
if point.x < 0 || point.x > 1 || point.y < 0 || point.y > 1 {
|
|
1171
|
+
print("setFocus: Coordinates out of bounds - x: \(point.x), y: \(point.y)")
|
|
1172
|
+
throw CameraControllerError.invalidOperation
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
var currentCamera: AVCaptureDevice?
|
|
1176
|
+
switch currentCameraPosition {
|
|
1177
|
+
case .front:
|
|
1178
|
+
currentCamera = self.frontCamera
|
|
1179
|
+
case .rear:
|
|
1180
|
+
currentCamera = self.rearCamera
|
|
1181
|
+
default: break
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
guard let device = currentCamera else {
|
|
1185
|
+
throw CameraControllerError.noCamerasAvailable
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
guard device.isFocusPointOfInterestSupported else {
|
|
1189
|
+
// Device doesn't support focus point of interest
|
|
1190
|
+
return
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
// Show focus indicator if requested and view is provided - only after validation
|
|
1194
|
+
if showIndicator, let view = view, let previewLayer = self.previewLayer {
|
|
1195
|
+
// Convert the device point to layer point for indicator display
|
|
1196
|
+
let layerPoint = previewLayer.layerPointConverted(fromCaptureDevicePoint: point)
|
|
1197
|
+
showFocusIndicator(at: layerPoint, in: view)
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
do {
|
|
1201
|
+
try device.lockForConfiguration()
|
|
1202
|
+
|
|
1203
|
+
// Set focus mode to auto if supported
|
|
1204
|
+
if device.isFocusModeSupported(.autoFocus) {
|
|
1205
|
+
device.focusMode = .autoFocus
|
|
1206
|
+
} else if device.isFocusModeSupported(.continuousAutoFocus) {
|
|
1207
|
+
device.focusMode = .continuousAutoFocus
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
// Set the focus point
|
|
1211
|
+
device.focusPointOfInterest = point
|
|
1212
|
+
|
|
1213
|
+
// Also set exposure point if supported
|
|
1214
|
+
if device.isExposurePointOfInterestSupported && device.isExposureModeSupported(.autoExpose) {
|
|
1215
|
+
device.exposureMode = .autoExpose
|
|
1216
|
+
device.exposurePointOfInterest = point
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
device.unlockForConfiguration()
|
|
1220
|
+
} catch {
|
|
1221
|
+
throw CameraControllerError.unknown
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
|
|
650
1225
|
func getFlashMode() throws -> String {
|
|
651
1226
|
switch self.flashMode {
|
|
652
1227
|
case .off:
|
|
@@ -677,7 +1252,7 @@ extension CameraController {
|
|
|
677
1252
|
|
|
678
1253
|
return device.uniqueID
|
|
679
1254
|
}
|
|
680
|
-
|
|
1255
|
+
|
|
681
1256
|
func getCurrentLensInfo() throws -> (focalLength: Float, deviceType: String, baseZoomRatio: Float) {
|
|
682
1257
|
var currentCamera: AVCaptureDevice?
|
|
683
1258
|
switch currentCameraPosition {
|
|
@@ -757,9 +1332,7 @@ extension CameraController {
|
|
|
757
1332
|
captureSession.commitConfiguration()
|
|
758
1333
|
// Restart the session if it was running before
|
|
759
1334
|
if wasRunning {
|
|
760
|
-
|
|
761
|
-
self?.captureSession?.startRunning()
|
|
762
|
-
}
|
|
1335
|
+
captureSession.startRunning()
|
|
763
1336
|
}
|
|
764
1337
|
}
|
|
765
1338
|
|
|
@@ -806,13 +1379,9 @@ extension CameraController {
|
|
|
806
1379
|
}
|
|
807
1380
|
|
|
808
1381
|
// Update video orientation
|
|
809
|
-
|
|
810
|
-
self?.updateVideoOrientation()
|
|
811
|
-
}
|
|
1382
|
+
self.updateVideoOrientation()
|
|
812
1383
|
}
|
|
813
1384
|
|
|
814
|
-
|
|
815
|
-
|
|
816
1385
|
func cleanup() {
|
|
817
1386
|
if let captureSession = self.captureSession {
|
|
818
1387
|
captureSession.stopRunning()
|
|
@@ -823,6 +1392,9 @@ extension CameraController {
|
|
|
823
1392
|
self.previewLayer?.removeFromSuperlayer()
|
|
824
1393
|
self.previewLayer = nil
|
|
825
1394
|
|
|
1395
|
+
self.focusIndicatorView?.removeFromSuperview()
|
|
1396
|
+
self.focusIndicatorView = nil
|
|
1397
|
+
|
|
826
1398
|
self.frontCameraInput = nil
|
|
827
1399
|
self.rearCameraInput = nil
|
|
828
1400
|
self.audioInput = nil
|
|
@@ -830,6 +1402,7 @@ extension CameraController {
|
|
|
830
1402
|
self.frontCamera = nil
|
|
831
1403
|
self.rearCamera = nil
|
|
832
1404
|
self.audioDevice = nil
|
|
1405
|
+
self.allDiscoveredDevices = []
|
|
833
1406
|
|
|
834
1407
|
self.dataOutput = nil
|
|
835
1408
|
self.photoOutput = nil
|
|
@@ -837,6 +1410,13 @@ extension CameraController {
|
|
|
837
1410
|
|
|
838
1411
|
self.captureSession = nil
|
|
839
1412
|
self.currentCameraPosition = nil
|
|
1413
|
+
|
|
1414
|
+
// Reset output preparation status
|
|
1415
|
+
self.outputsPrepared = false
|
|
1416
|
+
|
|
1417
|
+
// Reset first frame detection
|
|
1418
|
+
self.hasReceivedFirstFrame = false
|
|
1419
|
+
self.firstFrameReadyCallback = nil
|
|
840
1420
|
}
|
|
841
1421
|
|
|
842
1422
|
func captureVideo() throws {
|
|
@@ -913,6 +1493,11 @@ extension CameraController: UIGestureRecognizerDelegate {
|
|
|
913
1493
|
let point = tap.location(in: tap.view)
|
|
914
1494
|
let devicePoint = self.previewLayer?.captureDevicePointConverted(fromLayerPoint: point)
|
|
915
1495
|
|
|
1496
|
+
// Show focus indicator at the tap point
|
|
1497
|
+
if let view = tap.view {
|
|
1498
|
+
showFocusIndicator(at: point, in: view)
|
|
1499
|
+
}
|
|
1500
|
+
|
|
916
1501
|
do {
|
|
917
1502
|
try device.lockForConfiguration()
|
|
918
1503
|
defer { device.unlockForConfiguration() }
|
|
@@ -933,6 +1518,54 @@ extension CameraController: UIGestureRecognizerDelegate {
|
|
|
933
1518
|
}
|
|
934
1519
|
}
|
|
935
1520
|
|
|
1521
|
+
private func showFocusIndicator(at point: CGPoint, in view: UIView) {
|
|
1522
|
+
// Remove any existing focus indicator
|
|
1523
|
+
focusIndicatorView?.removeFromSuperview()
|
|
1524
|
+
|
|
1525
|
+
// Create a new focus indicator
|
|
1526
|
+
let indicator = UIView(frame: CGRect(x: 0, y: 0, width: 80, height: 80))
|
|
1527
|
+
indicator.center = point
|
|
1528
|
+
indicator.layer.borderColor = UIColor.yellow.cgColor
|
|
1529
|
+
indicator.layer.borderWidth = 2.0
|
|
1530
|
+
indicator.layer.cornerRadius = 40
|
|
1531
|
+
indicator.backgroundColor = UIColor.clear
|
|
1532
|
+
indicator.alpha = 0
|
|
1533
|
+
indicator.transform = CGAffineTransform(scaleX: 1.5, y: 1.5)
|
|
1534
|
+
|
|
1535
|
+
// Add inner circle for better visibility
|
|
1536
|
+
let innerCircle = UIView(frame: CGRect(x: 20, y: 20, width: 40, height: 40))
|
|
1537
|
+
innerCircle.layer.borderColor = UIColor.yellow.cgColor
|
|
1538
|
+
innerCircle.layer.borderWidth = 1.0
|
|
1539
|
+
innerCircle.layer.cornerRadius = 20
|
|
1540
|
+
innerCircle.backgroundColor = UIColor.clear
|
|
1541
|
+
indicator.addSubview(innerCircle)
|
|
1542
|
+
|
|
1543
|
+
view.addSubview(indicator)
|
|
1544
|
+
focusIndicatorView = indicator
|
|
1545
|
+
|
|
1546
|
+
// Animate the focus indicator
|
|
1547
|
+
UIView.animate(withDuration: 0.15, animations: {
|
|
1548
|
+
indicator.alpha = 1.0
|
|
1549
|
+
indicator.transform = CGAffineTransform.identity
|
|
1550
|
+
}) { _ in
|
|
1551
|
+
// Keep the indicator visible for a moment
|
|
1552
|
+
UIView.animate(withDuration: 0.2, delay: 0.5, options: [], animations: {
|
|
1553
|
+
indicator.alpha = 0.3
|
|
1554
|
+
}) { _ in
|
|
1555
|
+
// Fade out and remove
|
|
1556
|
+
UIView.animate(withDuration: 0.3, delay: 0.2, options: [], animations: {
|
|
1557
|
+
indicator.alpha = 0
|
|
1558
|
+
indicator.transform = CGAffineTransform(scaleX: 0.8, y: 0.8)
|
|
1559
|
+
}) { _ in
|
|
1560
|
+
indicator.removeFromSuperview()
|
|
1561
|
+
if self.focusIndicatorView == indicator {
|
|
1562
|
+
self.focusIndicatorView = nil
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
|
|
936
1569
|
@objc
|
|
937
1570
|
private func handlePinch(_ pinch: UIPinchGestureRecognizer) {
|
|
938
1571
|
guard let device = self.currentCameraPosition == .rear ? rearCamera : frontCamera else { return }
|
|
@@ -940,45 +1573,75 @@ extension CameraController: UIGestureRecognizerDelegate {
|
|
|
940
1573
|
let effectiveMaxZoom = min(device.maxAvailableVideoZoomFactor, self.saneMaxZoomFactor)
|
|
941
1574
|
func minMaxZoom(_ factor: CGFloat) -> CGFloat { return max(device.minAvailableVideoZoomFactor, min(factor, effectiveMaxZoom)) }
|
|
942
1575
|
|
|
943
|
-
|
|
1576
|
+
switch pinch.state {
|
|
1577
|
+
case .began:
|
|
1578
|
+
// Store the initial zoom factor when pinch begins
|
|
1579
|
+
zoomFactor = device.videoZoomFactor
|
|
1580
|
+
|
|
1581
|
+
case .changed:
|
|
1582
|
+
// Throttle zoom updates to prevent excessive CPU usage
|
|
1583
|
+
let currentTime = CACurrentMediaTime()
|
|
1584
|
+
guard currentTime - lastZoomUpdateTime >= zoomUpdateThrottle else { return }
|
|
1585
|
+
lastZoomUpdateTime = currentTime
|
|
1586
|
+
|
|
1587
|
+
// Calculate new zoom factor based on pinch scale
|
|
1588
|
+
let newScaleFactor = minMaxZoom(pinch.scale * zoomFactor)
|
|
1589
|
+
|
|
1590
|
+
// Use ramping for smooth zoom transitions during pinch
|
|
1591
|
+
// This provides much smoother performance than direct setting
|
|
944
1592
|
do {
|
|
945
1593
|
try device.lockForConfiguration()
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
device.
|
|
1594
|
+
// Use a very fast ramp rate for immediate response
|
|
1595
|
+
device.ramp(toVideoZoomFactor: newScaleFactor, withRate: 5.0)
|
|
1596
|
+
device.unlockForConfiguration()
|
|
949
1597
|
} catch {
|
|
950
|
-
debugPrint(error)
|
|
1598
|
+
debugPrint("Failed to set zoom: \(error)")
|
|
951
1599
|
}
|
|
952
|
-
}
|
|
953
1600
|
|
|
954
|
-
switch pinch.state {
|
|
955
|
-
case .began: fallthrough
|
|
956
|
-
case .changed:
|
|
957
|
-
let newScaleFactor = minMaxZoom(pinch.scale * zoomFactor)
|
|
958
|
-
update(scale: newScaleFactor)
|
|
959
1601
|
case .ended:
|
|
1602
|
+
// Update our internal zoom factor tracking
|
|
960
1603
|
zoomFactor = device.videoZoomFactor
|
|
1604
|
+
|
|
961
1605
|
default: break
|
|
962
1606
|
}
|
|
963
1607
|
}
|
|
964
1608
|
}
|
|
965
1609
|
|
|
966
1610
|
extension CameraController: AVCapturePhotoCaptureDelegate {
|
|
967
|
-
public func photoOutput(_
|
|
968
|
-
resolvedSettings: AVCaptureResolvedPhotoSettings, bracketSettings: AVCaptureBracketedStillImageSettings?, error: Swift.Error?) {
|
|
1611
|
+
public func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) {
|
|
969
1612
|
if let error = error {
|
|
970
1613
|
self.photoCaptureCompletionBlock?(nil, error)
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
1614
|
+
return
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
// Get the photo data using the modern API
|
|
1618
|
+
guard let imageData = photo.fileDataRepresentation() else {
|
|
975
1619
|
self.photoCaptureCompletionBlock?(nil, CameraControllerError.unknown)
|
|
1620
|
+
return
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
guard let image = UIImage(data: imageData) else {
|
|
1624
|
+
self.photoCaptureCompletionBlock?(nil, CameraControllerError.unknown)
|
|
1625
|
+
return
|
|
976
1626
|
}
|
|
1627
|
+
|
|
1628
|
+
self.photoCaptureCompletionBlock?(image.fixedOrientation(), nil)
|
|
977
1629
|
}
|
|
978
1630
|
}
|
|
979
1631
|
|
|
980
1632
|
extension CameraController: AVCaptureVideoDataOutputSampleBufferDelegate {
|
|
981
1633
|
func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
|
|
1634
|
+
// Check if we're waiting for the first frame
|
|
1635
|
+
if !hasReceivedFirstFrame, let firstFrameCallback = firstFrameReadyCallback {
|
|
1636
|
+
hasReceivedFirstFrame = true
|
|
1637
|
+
firstFrameCallback()
|
|
1638
|
+
firstFrameReadyCallback = nil
|
|
1639
|
+
// If no capture is in progress, we can return early
|
|
1640
|
+
if sampleBufferCaptureCompletionBlock == nil {
|
|
1641
|
+
return
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
|
|
982
1645
|
guard let completion = sampleBufferCaptureCompletionBlock else { return }
|
|
983
1646
|
|
|
984
1647
|
guard let imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else {
|
|
@@ -1028,6 +1691,7 @@ enum CameraControllerError: Swift.Error {
|
|
|
1028
1691
|
case cannotFindDocumentsDirectory
|
|
1029
1692
|
case fileVideoOutputNotFound
|
|
1030
1693
|
case unknown
|
|
1694
|
+
case invalidZoomLevel(min: CGFloat, max: CGFloat, requested: CGFloat)
|
|
1031
1695
|
}
|
|
1032
1696
|
|
|
1033
1697
|
public enum CameraPosition {
|
|
@@ -1054,6 +1718,8 @@ extension CameraControllerError: LocalizedError {
|
|
|
1054
1718
|
return NSLocalizedString("Cannot find documents directory", comment: "This should never happen")
|
|
1055
1719
|
case .fileVideoOutputNotFound:
|
|
1056
1720
|
return NSLocalizedString("Video recording is not available. Make sure the camera is properly initialized.", comment: "Video recording not available")
|
|
1721
|
+
case .invalidZoomLevel(let min, let max, let requested):
|
|
1722
|
+
return NSLocalizedString("Invalid zoom level. Must be between \(min) and \(max). Requested: \(requested)", comment: "Invalid Zoom Level")
|
|
1057
1723
|
}
|
|
1058
1724
|
}
|
|
1059
1725
|
}
|
|
@@ -1095,6 +1761,8 @@ extension UIImage {
|
|
|
1095
1761
|
print("right")
|
|
1096
1762
|
case .up, .upMirrored:
|
|
1097
1763
|
break
|
|
1764
|
+
@unknown default:
|
|
1765
|
+
break
|
|
1098
1766
|
}
|
|
1099
1767
|
|
|
1100
1768
|
// Flip image one more time if needed to, this is to prevent flipped image
|
|
@@ -1107,15 +1775,21 @@ extension UIImage {
|
|
|
1107
1775
|
transform.scaledBy(x: -1, y: 1)
|
|
1108
1776
|
case .up, .down, .left, .right:
|
|
1109
1777
|
break
|
|
1778
|
+
@unknown default:
|
|
1779
|
+
break
|
|
1110
1780
|
}
|
|
1111
1781
|
|
|
1112
1782
|
ctx.concatenate(transform)
|
|
1113
1783
|
|
|
1114
1784
|
switch imageOrientation {
|
|
1115
1785
|
case .left, .leftMirrored, .right, .rightMirrored:
|
|
1116
|
-
|
|
1786
|
+
if let cgImage = self.cgImage {
|
|
1787
|
+
ctx.draw(cgImage, in: CGRect(x: 0, y: 0, width: size.height, height: size.width))
|
|
1788
|
+
}
|
|
1117
1789
|
default:
|
|
1118
|
-
|
|
1790
|
+
if let cgImage = self.cgImage {
|
|
1791
|
+
ctx.draw(cgImage, in: CGRect(x: 0, y: 0, width: size.width, height: size.height))
|
|
1792
|
+
}
|
|
1119
1793
|
}
|
|
1120
1794
|
guard let newCGImage = ctx.makeImage() else { return nil }
|
|
1121
1795
|
return UIImage.init(cgImage: newCGImage, scale: 1, orientation: .up)
|