@capgo/camera-preview 7.4.0-beta.13 → 7.4.0-beta.16
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 +19 -18
- 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/src/main/java/com/ahm/capacitor/camera/preview/CameraPreview.java +271 -20
- package/android/src/main/java/com/ahm/capacitor/camera/preview/CameraXView.java +816 -134
- package/android/src/main/java/com/ahm/capacitor/camera/preview/model/CameraSessionConfiguration.java +9 -0
- package/dist/docs.json +39 -23
- package/dist/esm/definitions.d.ts +20 -10
- package/dist/esm/definitions.js.map +1 -1
- package/dist/esm/web.d.ts +1 -0
- package/dist/esm/web.js +266 -38
- package/dist/esm/web.js.map +1 -1
- package/dist/plugin.cjs.js +266 -38
- package/dist/plugin.cjs.js.map +1 -1
- package/dist/plugin.js +266 -38
- package/dist/plugin.js.map +1 -1
- package/ios/Sources/CapgoCameraPreview/CameraController.swift +430 -71
- package/ios/Sources/CapgoCameraPreview/Plugin.swift +139 -114
- package/package.json +1 -1
|
@@ -30,14 +30,13 @@ class CameraController: NSObject {
|
|
|
30
30
|
|
|
31
31
|
var previewLayer: AVCaptureVideoPreviewLayer?
|
|
32
32
|
var gridOverlayView: GridOverlayView?
|
|
33
|
+
var focusIndicatorView: UIView?
|
|
33
34
|
|
|
34
35
|
var flashMode = AVCaptureDevice.FlashMode.off
|
|
35
36
|
var photoCaptureCompletionBlock: ((UIImage?, Error?) -> Void)?
|
|
36
37
|
|
|
37
38
|
var sampleBufferCaptureCompletionBlock: ((UIImage?, Error?) -> Void)?
|
|
38
39
|
|
|
39
|
-
var highResolutionOutput: Bool = false
|
|
40
|
-
|
|
41
40
|
var audioDevice: AVCaptureDevice?
|
|
42
41
|
var audioInput: AVCaptureDeviceInput?
|
|
43
42
|
|
|
@@ -48,6 +47,10 @@ class CameraController: NSObject {
|
|
|
48
47
|
var videoFileURL: URL?
|
|
49
48
|
private let saneMaxZoomFactor: CGFloat = 25.5
|
|
50
49
|
|
|
50
|
+
// Track output preparation status
|
|
51
|
+
private var outputsPrepared: Bool = false
|
|
52
|
+
private let outputPreparationQueue = DispatchQueue(label: "camera.output.preparation", qos: .utility)
|
|
53
|
+
|
|
51
54
|
var isUsingMultiLensVirtualCamera: Bool {
|
|
52
55
|
guard let device = (currentCameraPosition == .rear) ? rearCamera : frontCamera else { return false }
|
|
53
56
|
// A rear multi-lens virtual camera will have a min zoom of 1.0 but support wider angles
|
|
@@ -65,18 +68,28 @@ extension CameraController {
|
|
|
65
68
|
// 1. Create and configure session
|
|
66
69
|
self.captureSession = AVCaptureSession()
|
|
67
70
|
|
|
68
|
-
// 2. Pre-configure session preset (can be changed later)
|
|
69
|
-
if captureSession!.canSetSessionPreset(.
|
|
71
|
+
// 2. Pre-configure session preset (can be changed later) - use medium for faster startup
|
|
72
|
+
if captureSession!.canSetSessionPreset(.medium) {
|
|
73
|
+
captureSession!.sessionPreset = .medium // Start with medium, upgrade later if needed
|
|
74
|
+
} else if captureSession!.canSetSessionPreset(.high) {
|
|
70
75
|
captureSession!.sessionPreset = .high
|
|
71
76
|
}
|
|
72
77
|
|
|
73
|
-
// 3. Discover
|
|
74
|
-
discoverAndConfigureCameras()
|
|
78
|
+
// 3. Discover cameras on-demand (only when needed for better startup performance)
|
|
79
|
+
// discoverAndConfigureCameras() - moved to lazy loading
|
|
75
80
|
|
|
76
|
-
// 4. Pre-create outputs
|
|
77
|
-
|
|
81
|
+
// // 4. Pre-create outputs asynchronously to avoid blocking camera opening
|
|
82
|
+
// outputPreparationQueue.async { [weak self] in
|
|
83
|
+
// self?.prepareOutputs()
|
|
84
|
+
// }
|
|
85
|
+
|
|
86
|
+
print("[CameraPreview] Full session preparation complete - cameras will be discovered on-demand, outputs being prepared asynchronously")
|
|
87
|
+
}
|
|
78
88
|
|
|
79
|
-
|
|
89
|
+
private func ensureCamerasDiscovered() {
|
|
90
|
+
// Rediscover cameras if the array is empty OR if the camera pointers are nil
|
|
91
|
+
guard allDiscoveredDevices.isEmpty || (rearCamera == nil && frontCamera == nil) else { return }
|
|
92
|
+
discoverAndConfigureCameras()
|
|
80
93
|
}
|
|
81
94
|
|
|
82
95
|
private func discoverAndConfigureCameras() {
|
|
@@ -137,67 +150,211 @@ extension CameraController {
|
|
|
137
150
|
}
|
|
138
151
|
|
|
139
152
|
private func prepareOutputs() {
|
|
140
|
-
// Pre-create photo output
|
|
153
|
+
// Pre-create photo output with optimized settings
|
|
141
154
|
self.photoOutput = AVCapturePhotoOutput()
|
|
142
|
-
self.photoOutput?.isHighResolutionCaptureEnabled = false //
|
|
155
|
+
self.photoOutput?.isHighResolutionCaptureEnabled = false // Start with lower resolution for speed
|
|
156
|
+
|
|
157
|
+
// Configure photo output for better performance
|
|
158
|
+
if #available(iOS 13.0, *) {
|
|
159
|
+
self.photoOutput?.maxPhotoQualityPrioritization = .speed // Prioritize speed over quality initially
|
|
160
|
+
}
|
|
143
161
|
|
|
144
162
|
// Pre-create video output
|
|
145
163
|
self.fileVideoOutput = AVCaptureMovieFileOutput()
|
|
146
164
|
|
|
147
|
-
// Pre-create data output
|
|
165
|
+
// Pre-create data output with optimized settings
|
|
148
166
|
self.dataOutput = AVCaptureVideoDataOutput()
|
|
149
167
|
self.dataOutput?.videoSettings = [
|
|
150
168
|
(kCVPixelBufferPixelFormatTypeKey as String): NSNumber(value: kCVPixelFormatType_32BGRA as UInt32)
|
|
151
169
|
]
|
|
152
170
|
self.dataOutput?.alwaysDiscardsLateVideoFrames = true
|
|
153
171
|
|
|
154
|
-
|
|
172
|
+
// Use a background queue for sample buffer processing to avoid blocking main thread
|
|
173
|
+
let dataOutputQueue = DispatchQueue(label: "camera.data.output", qos: .userInitiated)
|
|
174
|
+
self.dataOutput?.setSampleBufferDelegate(nil, queue: dataOutputQueue) // Will be set later
|
|
175
|
+
|
|
176
|
+
// Mark outputs as prepared
|
|
177
|
+
self.outputsPrepared = true
|
|
178
|
+
|
|
179
|
+
print("[CameraPreview] Outputs pre-created with performance optimizations")
|
|
155
180
|
}
|
|
156
181
|
|
|
157
|
-
func
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
182
|
+
private func waitForOutputsToBeReady() {
|
|
183
|
+
// If outputs are already prepared, return immediately
|
|
184
|
+
if outputsPrepared {
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Wait for outputs to be prepared with a timeout
|
|
189
|
+
let semaphore = DispatchSemaphore(value: 0)
|
|
190
|
+
var outputsReady = false
|
|
191
|
+
|
|
192
|
+
// Check for outputs readiness periodically
|
|
193
|
+
let timer = Timer.scheduledTimer(withTimeInterval: 0.01, repeats: true) { timer in
|
|
194
|
+
if self.outputsPrepared {
|
|
195
|
+
outputsReady = true
|
|
196
|
+
timer.invalidate()
|
|
197
|
+
semaphore.signal()
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Wait for outputs to be ready or timeout after 2 seconds
|
|
202
|
+
let timeout = DispatchTime.now() + .seconds(2)
|
|
203
|
+
let result = semaphore.wait(timeout: timeout)
|
|
204
|
+
|
|
205
|
+
timer.invalidate()
|
|
206
|
+
|
|
207
|
+
if result == .timedOut && !outputsReady {
|
|
208
|
+
print("[CameraPreview] Warning: Timed out waiting for outputs to be prepared, proceeding anyway")
|
|
209
|
+
// Fallback: prepare outputs synchronously if async preparation failed
|
|
210
|
+
if !outputsPrepared {
|
|
211
|
+
prepareOutputs()
|
|
212
|
+
}
|
|
213
|
+
} else {
|
|
214
|
+
print("[CameraPreview] Outputs ready, proceeding with camera preparation")
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
func upgradeQualitySettings() {
|
|
219
|
+
guard let captureSession = self.captureSession else { return }
|
|
220
|
+
|
|
221
|
+
// Upgrade session preset to high quality after initial startup
|
|
222
|
+
DispatchQueue.global(qos: .utility).async { [weak self] in
|
|
223
|
+
guard let self = self else { return }
|
|
224
|
+
|
|
225
|
+
captureSession.beginConfiguration()
|
|
226
|
+
|
|
227
|
+
// Upgrade to high quality preset
|
|
228
|
+
if captureSession.canSetSessionPreset(.high) && captureSession.sessionPreset != .high {
|
|
229
|
+
captureSession.sessionPreset = .high
|
|
230
|
+
print("[CameraPreview] Upgraded session preset to high quality")
|
|
163
231
|
}
|
|
164
232
|
|
|
165
|
-
|
|
166
|
-
|
|
233
|
+
// Upgrade photo output quality
|
|
234
|
+
if let photoOutput = self.photoOutput {
|
|
235
|
+
photoOutput.isHighResolutionCaptureEnabled = true
|
|
236
|
+
if #available(iOS 13.0, *) {
|
|
237
|
+
photoOutput.maxPhotoQualityPrioritization = .quality
|
|
238
|
+
}
|
|
239
|
+
print("[CameraPreview] Upgraded photo output to high resolution")
|
|
167
240
|
}
|
|
168
241
|
|
|
169
|
-
|
|
242
|
+
captureSession.commitConfiguration()
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
func prepare(cameraPosition: String, deviceId: String? = nil, disableAudio: Bool, cameraMode: Bool, aspectRatio: String? = nil, initialZoomLevel: Float = 1.0, completionHandler: @escaping (Error?) -> Void) {
|
|
247
|
+
// Use background queue for preparation to avoid blocking main thread
|
|
248
|
+
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
|
249
|
+
guard let self = self else {
|
|
250
|
+
DispatchQueue.main.async {
|
|
251
|
+
completionHandler(CameraControllerError.unknown)
|
|
252
|
+
}
|
|
253
|
+
return
|
|
254
|
+
}
|
|
170
255
|
|
|
171
|
-
|
|
172
|
-
|
|
256
|
+
do {
|
|
257
|
+
// Session and outputs already created in load(), just configure user-specific settings
|
|
258
|
+
if self.captureSession == nil {
|
|
259
|
+
// Fallback if prepareFullSession() wasn't called
|
|
260
|
+
self.prepareFullSession()
|
|
261
|
+
}
|
|
173
262
|
|
|
174
|
-
|
|
175
|
-
|
|
263
|
+
guard let captureSession = self.captureSession else {
|
|
264
|
+
throw CameraControllerError.captureSessionIsMissing
|
|
265
|
+
}
|
|
176
266
|
|
|
177
|
-
|
|
178
|
-
captureSession.startRunning()
|
|
179
|
-
print("[CameraPreview] Session started")
|
|
267
|
+
print("[CameraPreview] Fast prepare - using pre-initialized session")
|
|
180
268
|
|
|
181
|
-
|
|
269
|
+
// Pre-create outputs asynchronously to avoid blocking camera opening
|
|
270
|
+
outputPreparationQueue.async { [weak self] in
|
|
271
|
+
self?.prepareOutputs()
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// // Configure device inputs for the requested camera
|
|
275
|
+
try self.configureDeviceInputs(cameraPosition: cameraPosition, deviceId: deviceId, disableAudio: disableAudio)
|
|
276
|
+
|
|
277
|
+
// Start the session on background thread (AVCaptureSession.startRunning() is thread-safe)
|
|
278
|
+
captureSession.startRunning()
|
|
279
|
+
print("[CameraPreview] Session started")
|
|
280
|
+
|
|
281
|
+
// Validate and set initial zoom level asynchronously
|
|
282
|
+
if initialZoomLevel != 1.0 {
|
|
283
|
+
DispatchQueue.main.async { [weak self] in
|
|
284
|
+
self?.setInitialZoom(level: initialZoomLevel)
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Call completion on main thread
|
|
289
|
+
DispatchQueue.main.async {
|
|
290
|
+
completionHandler(nil)
|
|
291
|
+
|
|
292
|
+
// Upgrade quality settings after a short delay for better user experience
|
|
293
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
|
294
|
+
guard let self = self else { return }
|
|
295
|
+
|
|
296
|
+
// Wait for outputs to be prepared before proceeding
|
|
297
|
+
self.waitForOutputsToBeReady()
|
|
298
|
+
|
|
299
|
+
// Add outputs to session and apply user settings
|
|
300
|
+
do {
|
|
301
|
+
try self.addOutputsToSession(cameraMode: cameraMode, aspectRatio: aspectRatio)
|
|
302
|
+
print("[CameraPreview] Outputs successfully added to session")
|
|
303
|
+
} catch {
|
|
304
|
+
print("[CameraPreview] Error adding outputs to session: \(error)")
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
self.upgradeQualitySettings()
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
} catch {
|
|
311
|
+
DispatchQueue.main.async {
|
|
312
|
+
completionHandler(error)
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
private func setInitialZoom(level: Float) {
|
|
319
|
+
let device = (currentCameraPosition == .rear) ? rearCamera : frontCamera
|
|
320
|
+
guard let device = device else { return }
|
|
321
|
+
|
|
322
|
+
let minZoom = device.minAvailableVideoZoomFactor
|
|
323
|
+
let maxZoom = min(device.maxAvailableVideoZoomFactor, saneMaxZoomFactor)
|
|
324
|
+
|
|
325
|
+
guard CGFloat(level) >= minZoom && CGFloat(level) <= maxZoom else {
|
|
326
|
+
print("[CameraPreview] Initial zoom level \(level) out of range (\(minZoom)-\(maxZoom))")
|
|
327
|
+
return
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
do {
|
|
331
|
+
try device.lockForConfiguration()
|
|
332
|
+
device.videoZoomFactor = CGFloat(level)
|
|
333
|
+
device.unlockForConfiguration()
|
|
334
|
+
self.zoomFactor = CGFloat(level)
|
|
335
|
+
print("[CameraPreview] Set initial zoom to \(level)")
|
|
182
336
|
} catch {
|
|
183
|
-
|
|
337
|
+
print("[CameraPreview] Failed to set initial zoom: \(error)")
|
|
184
338
|
}
|
|
185
339
|
}
|
|
186
340
|
|
|
187
341
|
private func configureDeviceInputs(cameraPosition: String, deviceId: String?, disableAudio: Bool) throws {
|
|
188
342
|
guard let captureSession = self.captureSession else { throw CameraControllerError.captureSessionIsMissing }
|
|
189
343
|
|
|
344
|
+
// Ensure cameras are discovered before configuring inputs
|
|
345
|
+
ensureCamerasDiscovered()
|
|
346
|
+
|
|
190
347
|
var selectedDevice: AVCaptureDevice?
|
|
191
348
|
|
|
192
|
-
// If deviceId is specified, find that specific device from
|
|
349
|
+
// If deviceId is specified, find that specific device from discovered devices
|
|
193
350
|
if let deviceId = deviceId {
|
|
194
351
|
selectedDevice = self.allDiscoveredDevices.first(where: { $0.uniqueID == deviceId })
|
|
195
352
|
guard selectedDevice != nil else {
|
|
196
|
-
print("[CameraPreview] ERROR: Device with ID \(deviceId) not found in
|
|
353
|
+
print("[CameraPreview] ERROR: Device with ID \(deviceId) not found in discovered devices")
|
|
197
354
|
throw CameraControllerError.noCamerasAvailable
|
|
198
355
|
}
|
|
199
356
|
} else {
|
|
200
|
-
// Use position-based selection from
|
|
357
|
+
// Use position-based selection from discovered cameras
|
|
201
358
|
if cameraPosition == "rear" {
|
|
202
359
|
selectedDevice = self.rearCamera
|
|
203
360
|
} else if cameraPosition == "front" {
|
|
@@ -223,16 +380,16 @@ extension CameraController {
|
|
|
223
380
|
self.rearCameraInput = deviceInput
|
|
224
381
|
self.currentCameraPosition = .rear
|
|
225
382
|
|
|
226
|
-
// Configure zoom for multi-camera systems
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
383
|
+
// Configure zoom for multi-camera systems - simplified and faster
|
|
384
|
+
if finalDevice.isVirtualDevice && finalDevice.constituentDevices.count > 1 {
|
|
385
|
+
try finalDevice.lockForConfiguration()
|
|
386
|
+
let defaultWideAngleZoom: CGFloat = 1.0 // Changed from 2.0 to 1.0 for faster startup
|
|
230
387
|
if defaultWideAngleZoom >= finalDevice.minAvailableVideoZoomFactor && defaultWideAngleZoom <= finalDevice.maxAvailableVideoZoomFactor {
|
|
231
388
|
print("[CameraPreview] Setting initial zoom to \(defaultWideAngleZoom)")
|
|
232
389
|
finalDevice.videoZoomFactor = defaultWideAngleZoom
|
|
233
390
|
}
|
|
391
|
+
finalDevice.unlockForConfiguration()
|
|
234
392
|
}
|
|
235
|
-
finalDevice.unlockForConfiguration()
|
|
236
393
|
}
|
|
237
394
|
} else {
|
|
238
395
|
throw CameraControllerError.inputsAreInvalid
|
|
@@ -257,6 +414,10 @@ extension CameraController {
|
|
|
257
414
|
private func addOutputsToSession(cameraMode: Bool, aspectRatio: String?) throws {
|
|
258
415
|
guard let captureSession = self.captureSession else { throw CameraControllerError.captureSessionIsMissing }
|
|
259
416
|
|
|
417
|
+
// Begin configuration to batch all changes
|
|
418
|
+
captureSession.beginConfiguration()
|
|
419
|
+
defer { captureSession.commitConfiguration() }
|
|
420
|
+
|
|
260
421
|
// Update session preset based on aspect ratio if needed
|
|
261
422
|
var targetPreset: AVCaptureSession.Preset = .high // Default to high quality
|
|
262
423
|
|
|
@@ -283,7 +444,7 @@ extension CameraController {
|
|
|
283
444
|
|
|
284
445
|
// Add photo output (already created in prepareOutputs)
|
|
285
446
|
if let photoOutput = self.photoOutput, captureSession.canAddOutput(photoOutput) {
|
|
286
|
-
photoOutput.isHighResolutionCaptureEnabled =
|
|
447
|
+
photoOutput.isHighResolutionCaptureEnabled = true
|
|
287
448
|
captureSession.addOutput(photoOutput)
|
|
288
449
|
}
|
|
289
450
|
|
|
@@ -295,9 +456,10 @@ extension CameraController {
|
|
|
295
456
|
// Add data output
|
|
296
457
|
if let dataOutput = self.dataOutput, captureSession.canAddOutput(dataOutput) {
|
|
297
458
|
captureSession.addOutput(dataOutput)
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
459
|
+
// Set delegate after outputs are added for better performance
|
|
460
|
+
DispatchQueue.main.async {
|
|
461
|
+
dataOutput.setSampleBufferDelegate(self, queue: DispatchQueue.main)
|
|
462
|
+
}
|
|
301
463
|
}
|
|
302
464
|
}
|
|
303
465
|
|
|
@@ -306,33 +468,32 @@ extension CameraController {
|
|
|
306
468
|
|
|
307
469
|
print("[CameraPreview] displayPreview called with view frame: \(view.frame)")
|
|
308
470
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
// Optimize preview layer for better quality
|
|
313
|
-
self.previewLayer?.connection?.videoOrientation = .portrait
|
|
314
|
-
self.previewLayer?.isOpaque = true
|
|
471
|
+
// Create and configure preview layer in one go
|
|
472
|
+
let previewLayer = AVCaptureVideoPreviewLayer(session: captureSession)
|
|
315
473
|
|
|
316
|
-
//
|
|
317
|
-
|
|
474
|
+
// Batch all layer configuration to avoid multiple redraws
|
|
475
|
+
CATransaction.begin()
|
|
476
|
+
CATransaction.setDisableActions(true)
|
|
318
477
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
478
|
+
previewLayer.videoGravity = AVLayerVideoGravity.resizeAspectFill
|
|
479
|
+
previewLayer.connection?.videoOrientation = .portrait
|
|
480
|
+
previewLayer.isOpaque = true
|
|
481
|
+
previewLayer.contentsScale = UIScreen.main.scale
|
|
482
|
+
previewLayer.frame = view.bounds
|
|
323
483
|
|
|
324
|
-
|
|
484
|
+
// Insert layer and store reference
|
|
485
|
+
view.layer.insertSublayer(previewLayer, at: 0)
|
|
486
|
+
self.previewLayer = previewLayer
|
|
325
487
|
|
|
326
|
-
// Disable animation for frame update
|
|
327
|
-
CATransaction.begin()
|
|
328
|
-
CATransaction.setDisableActions(true)
|
|
329
|
-
self.previewLayer?.frame = view.bounds
|
|
330
488
|
CATransaction.commit()
|
|
331
489
|
|
|
332
490
|
print("[CameraPreview] Set preview layer frame to view bounds: \(view.bounds)")
|
|
333
491
|
print("[CameraPreview] Session preset: \(captureSession.sessionPreset.rawValue)")
|
|
334
492
|
|
|
335
|
-
|
|
493
|
+
// Update video orientation asynchronously to avoid blocking
|
|
494
|
+
DispatchQueue.main.async { [weak self] in
|
|
495
|
+
self?.updateVideoOrientation()
|
|
496
|
+
}
|
|
336
497
|
}
|
|
337
498
|
|
|
338
499
|
func addGridOverlay(to view: UIView, gridMode: String) {
|
|
@@ -504,7 +665,7 @@ extension CameraController {
|
|
|
504
665
|
self.updateVideoOrientation()
|
|
505
666
|
}
|
|
506
667
|
|
|
507
|
-
func captureImage(width: Int?, height: Int?, quality: Float, gpsLocation: CLLocation?, completion: @escaping (UIImage?, Error?) -> Void) {
|
|
668
|
+
func captureImage(width: Int?, height: Int?, aspectRatio: String?, quality: Float, gpsLocation: CLLocation?, completion: @escaping (UIImage?, Error?) -> Void) {
|
|
508
669
|
guard let photoOutput = self.photoOutput else {
|
|
509
670
|
completion(nil, NSError(domain: "Camera", code: 0, userInfo: [NSLocalizedDescriptionKey: "Photo output is not available"]))
|
|
510
671
|
return
|
|
@@ -512,6 +673,26 @@ extension CameraController {
|
|
|
512
673
|
|
|
513
674
|
let settings = AVCapturePhotoSettings()
|
|
514
675
|
|
|
676
|
+
// Apply the current flash mode to the photo settings
|
|
677
|
+
// Check if the current device supports flash
|
|
678
|
+
var currentCamera: AVCaptureDevice?
|
|
679
|
+
switch currentCameraPosition {
|
|
680
|
+
case .front:
|
|
681
|
+
currentCamera = self.frontCamera
|
|
682
|
+
case .rear:
|
|
683
|
+
currentCamera = self.rearCamera
|
|
684
|
+
default:
|
|
685
|
+
break
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// Only apply flash if the device has flash and the flash mode is supported
|
|
689
|
+
if let device = currentCamera, device.hasFlash {
|
|
690
|
+
let supportedFlashModes = photoOutput.supportedFlashModes
|
|
691
|
+
if supportedFlashModes.contains(self.flashMode) {
|
|
692
|
+
settings.flashMode = self.flashMode
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
515
696
|
self.photoCaptureCompletionBlock = { (image, error) in
|
|
516
697
|
if let error = error {
|
|
517
698
|
completion(nil, error)
|
|
@@ -527,12 +708,38 @@ extension CameraController {
|
|
|
527
708
|
self.addGPSMetadata(to: image, location: location)
|
|
528
709
|
}
|
|
529
710
|
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
711
|
+
var finalImage = image
|
|
712
|
+
|
|
713
|
+
// Handle aspect ratio if no width/height specified
|
|
714
|
+
if width == nil && height == nil, let aspectRatio = aspectRatio {
|
|
715
|
+
let components = aspectRatio.split(separator: ":").compactMap { Double($0) }
|
|
716
|
+
if components.count == 2 {
|
|
717
|
+
// For capture in portrait orientation, swap the aspect ratio (16:9 becomes 9:16)
|
|
718
|
+
let isPortrait = image.size.height > image.size.width
|
|
719
|
+
let targetAspectRatio = isPortrait ? components[1] / components[0] : components[0] / components[1]
|
|
720
|
+
let imageSize = image.size
|
|
721
|
+
let originalAspectRatio = imageSize.width / imageSize.height
|
|
722
|
+
|
|
723
|
+
var targetSize = imageSize
|
|
724
|
+
|
|
725
|
+
if originalAspectRatio > targetAspectRatio {
|
|
726
|
+
// Original is wider than target - fit by height
|
|
727
|
+
targetSize.width = imageSize.height * CGFloat(targetAspectRatio)
|
|
728
|
+
} else {
|
|
729
|
+
// Original is taller than target - fit by width
|
|
730
|
+
targetSize.height = imageSize.width / CGFloat(targetAspectRatio)
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// Center crop the image
|
|
734
|
+
if let croppedImage = self.cropImageToAspectRatio(image: image, targetSize: targetSize) {
|
|
735
|
+
finalImage = croppedImage
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
} else if let width = width, let height = height {
|
|
739
|
+
finalImage = self.resizeImage(image: image, to: CGSize(width: width, height: height))!
|
|
535
740
|
}
|
|
741
|
+
|
|
742
|
+
completion(finalImage, nil)
|
|
536
743
|
}
|
|
537
744
|
|
|
538
745
|
photoOutput.capturePhoto(with: settings, delegate: self)
|
|
@@ -574,6 +781,23 @@ extension CameraController {
|
|
|
574
781
|
}
|
|
575
782
|
return resizedImage
|
|
576
783
|
}
|
|
784
|
+
|
|
785
|
+
func cropImageToAspectRatio(image: UIImage, targetSize: CGSize) -> UIImage? {
|
|
786
|
+
let imageSize = image.size
|
|
787
|
+
|
|
788
|
+
// Calculate the crop rect - center crop
|
|
789
|
+
let xOffset = (imageSize.width - targetSize.width) / 2
|
|
790
|
+
let yOffset = (imageSize.height - targetSize.height) / 2
|
|
791
|
+
let cropRect = CGRect(x: xOffset, y: yOffset, width: targetSize.width, height: targetSize.height)
|
|
792
|
+
|
|
793
|
+
// Create the cropped image
|
|
794
|
+
guard let cgImage = image.cgImage,
|
|
795
|
+
let croppedCGImage = cgImage.cropping(to: cropRect) else {
|
|
796
|
+
return nil
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
return UIImage(cgImage: croppedCGImage, scale: image.scale, orientation: image.imageOrientation)
|
|
800
|
+
}
|
|
577
801
|
|
|
578
802
|
func captureSample(completion: @escaping (UIImage?, Error?) -> Void) {
|
|
579
803
|
guard let captureSession = captureSession,
|
|
@@ -749,7 +973,7 @@ extension CameraController {
|
|
|
749
973
|
)
|
|
750
974
|
}
|
|
751
975
|
|
|
752
|
-
func setZoom(level: CGFloat, ramp: Bool) throws {
|
|
976
|
+
func setZoom(level: CGFloat, ramp: Bool, autoFocus: Bool = true) throws {
|
|
753
977
|
var currentCamera: AVCaptureDevice?
|
|
754
978
|
switch currentCameraPosition {
|
|
755
979
|
case .front:
|
|
@@ -780,12 +1004,73 @@ extension CameraController {
|
|
|
780
1004
|
|
|
781
1005
|
// Update our internal zoom factor tracking
|
|
782
1006
|
self.zoomFactor = zoomLevel
|
|
1007
|
+
|
|
1008
|
+
// Trigger autofocus after zoom if requested
|
|
1009
|
+
if autoFocus {
|
|
1010
|
+
self.triggerAutoFocus()
|
|
1011
|
+
}
|
|
783
1012
|
} catch {
|
|
784
1013
|
throw CameraControllerError.invalidOperation
|
|
785
1014
|
}
|
|
786
1015
|
}
|
|
787
1016
|
|
|
788
|
-
func
|
|
1017
|
+
private func triggerAutoFocus() {
|
|
1018
|
+
var currentCamera: AVCaptureDevice?
|
|
1019
|
+
switch currentCameraPosition {
|
|
1020
|
+
case .front:
|
|
1021
|
+
currentCamera = self.frontCamera
|
|
1022
|
+
case .rear:
|
|
1023
|
+
currentCamera = self.rearCamera
|
|
1024
|
+
default: break
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
guard let device = currentCamera else {
|
|
1028
|
+
return
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
// Focus on the center of the preview (0.5, 0.5)
|
|
1032
|
+
let centerPoint = CGPoint(x: 0.5, y: 0.5)
|
|
1033
|
+
|
|
1034
|
+
do {
|
|
1035
|
+
try device.lockForConfiguration()
|
|
1036
|
+
|
|
1037
|
+
// Set focus mode to auto if supported
|
|
1038
|
+
if device.isFocusModeSupported(.autoFocus) {
|
|
1039
|
+
device.focusMode = .autoFocus
|
|
1040
|
+
if device.isFocusPointOfInterestSupported {
|
|
1041
|
+
device.focusPointOfInterest = centerPoint
|
|
1042
|
+
}
|
|
1043
|
+
} else if device.isFocusModeSupported(.continuousAutoFocus) {
|
|
1044
|
+
device.focusMode = .continuousAutoFocus
|
|
1045
|
+
if device.isFocusPointOfInterestSupported {
|
|
1046
|
+
device.focusPointOfInterest = centerPoint
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
// Also set exposure point if supported
|
|
1051
|
+
if device.isExposurePointOfInterestSupported && device.isExposureModeSupported(.autoExpose) {
|
|
1052
|
+
device.exposureMode = .autoExpose
|
|
1053
|
+
device.exposurePointOfInterest = centerPoint
|
|
1054
|
+
} else if device.isExposureModeSupported(.continuousAutoExposure) {
|
|
1055
|
+
device.exposureMode = .continuousAutoExposure
|
|
1056
|
+
if device.isExposurePointOfInterestSupported {
|
|
1057
|
+
device.exposurePointOfInterest = centerPoint
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
device.unlockForConfiguration()
|
|
1062
|
+
} catch {
|
|
1063
|
+
// Silently ignore errors during autofocus
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
func setFocus(at point: CGPoint, showIndicator: Bool = false, in view: UIView? = nil) throws {
|
|
1068
|
+
// Validate that coordinates are within bounds (0-1 range for device coordinates)
|
|
1069
|
+
if point.x < 0 || point.x > 1 || point.y < 0 || point.y > 1 {
|
|
1070
|
+
print("setFocus: Coordinates out of bounds - x: \(point.x), y: \(point.y)")
|
|
1071
|
+
throw CameraControllerError.invalidOperation
|
|
1072
|
+
}
|
|
1073
|
+
|
|
789
1074
|
var currentCamera: AVCaptureDevice?
|
|
790
1075
|
switch currentCameraPosition {
|
|
791
1076
|
case .front:
|
|
@@ -804,6 +1089,13 @@ extension CameraController {
|
|
|
804
1089
|
return
|
|
805
1090
|
}
|
|
806
1091
|
|
|
1092
|
+
// Show focus indicator if requested and view is provided - only after validation
|
|
1093
|
+
if showIndicator, let view = view, let previewLayer = self.previewLayer {
|
|
1094
|
+
// Convert the device point to layer point for indicator display
|
|
1095
|
+
let layerPoint = previewLayer.layerPointConverted(fromCaptureDevicePoint: point)
|
|
1096
|
+
showFocusIndicator(at: layerPoint, in: view)
|
|
1097
|
+
}
|
|
1098
|
+
|
|
807
1099
|
do {
|
|
808
1100
|
try device.lockForConfiguration()
|
|
809
1101
|
|
|
@@ -999,6 +1291,9 @@ extension CameraController {
|
|
|
999
1291
|
self.previewLayer?.removeFromSuperlayer()
|
|
1000
1292
|
self.previewLayer = nil
|
|
1001
1293
|
|
|
1294
|
+
self.focusIndicatorView?.removeFromSuperview()
|
|
1295
|
+
self.focusIndicatorView = nil
|
|
1296
|
+
|
|
1002
1297
|
self.frontCameraInput = nil
|
|
1003
1298
|
self.rearCameraInput = nil
|
|
1004
1299
|
self.audioInput = nil
|
|
@@ -1006,6 +1301,7 @@ extension CameraController {
|
|
|
1006
1301
|
self.frontCamera = nil
|
|
1007
1302
|
self.rearCamera = nil
|
|
1008
1303
|
self.audioDevice = nil
|
|
1304
|
+
self.allDiscoveredDevices = []
|
|
1009
1305
|
|
|
1010
1306
|
self.dataOutput = nil
|
|
1011
1307
|
self.photoOutput = nil
|
|
@@ -1013,6 +1309,9 @@ extension CameraController {
|
|
|
1013
1309
|
|
|
1014
1310
|
self.captureSession = nil
|
|
1015
1311
|
self.currentCameraPosition = nil
|
|
1312
|
+
|
|
1313
|
+
// Reset output preparation status
|
|
1314
|
+
self.outputsPrepared = false
|
|
1016
1315
|
}
|
|
1017
1316
|
|
|
1018
1317
|
func captureVideo() throws {
|
|
@@ -1089,6 +1388,11 @@ extension CameraController: UIGestureRecognizerDelegate {
|
|
|
1089
1388
|
let point = tap.location(in: tap.view)
|
|
1090
1389
|
let devicePoint = self.previewLayer?.captureDevicePointConverted(fromLayerPoint: point)
|
|
1091
1390
|
|
|
1391
|
+
// Show focus indicator at the tap point
|
|
1392
|
+
if let view = tap.view {
|
|
1393
|
+
showFocusIndicator(at: point, in: view)
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1092
1396
|
do {
|
|
1093
1397
|
try device.lockForConfiguration()
|
|
1094
1398
|
defer { device.unlockForConfiguration() }
|
|
@@ -1109,6 +1413,54 @@ extension CameraController: UIGestureRecognizerDelegate {
|
|
|
1109
1413
|
}
|
|
1110
1414
|
}
|
|
1111
1415
|
|
|
1416
|
+
private func showFocusIndicator(at point: CGPoint, in view: UIView) {
|
|
1417
|
+
// Remove any existing focus indicator
|
|
1418
|
+
focusIndicatorView?.removeFromSuperview()
|
|
1419
|
+
|
|
1420
|
+
// Create a new focus indicator
|
|
1421
|
+
let indicator = UIView(frame: CGRect(x: 0, y: 0, width: 80, height: 80))
|
|
1422
|
+
indicator.center = point
|
|
1423
|
+
indicator.layer.borderColor = UIColor.yellow.cgColor
|
|
1424
|
+
indicator.layer.borderWidth = 2.0
|
|
1425
|
+
indicator.layer.cornerRadius = 40
|
|
1426
|
+
indicator.backgroundColor = UIColor.clear
|
|
1427
|
+
indicator.alpha = 0
|
|
1428
|
+
indicator.transform = CGAffineTransform(scaleX: 1.5, y: 1.5)
|
|
1429
|
+
|
|
1430
|
+
// Add inner circle for better visibility
|
|
1431
|
+
let innerCircle = UIView(frame: CGRect(x: 20, y: 20, width: 40, height: 40))
|
|
1432
|
+
innerCircle.layer.borderColor = UIColor.yellow.cgColor
|
|
1433
|
+
innerCircle.layer.borderWidth = 1.0
|
|
1434
|
+
innerCircle.layer.cornerRadius = 20
|
|
1435
|
+
innerCircle.backgroundColor = UIColor.clear
|
|
1436
|
+
indicator.addSubview(innerCircle)
|
|
1437
|
+
|
|
1438
|
+
view.addSubview(indicator)
|
|
1439
|
+
focusIndicatorView = indicator
|
|
1440
|
+
|
|
1441
|
+
// Animate the focus indicator
|
|
1442
|
+
UIView.animate(withDuration: 0.15, animations: {
|
|
1443
|
+
indicator.alpha = 1.0
|
|
1444
|
+
indicator.transform = CGAffineTransform.identity
|
|
1445
|
+
}) { _ in
|
|
1446
|
+
// Keep the indicator visible for a moment
|
|
1447
|
+
UIView.animate(withDuration: 0.2, delay: 0.5, options: [], animations: {
|
|
1448
|
+
indicator.alpha = 0.3
|
|
1449
|
+
}) { _ in
|
|
1450
|
+
// Fade out and remove
|
|
1451
|
+
UIView.animate(withDuration: 0.3, delay: 0.2, options: [], animations: {
|
|
1452
|
+
indicator.alpha = 0
|
|
1453
|
+
indicator.transform = CGAffineTransform(scaleX: 0.8, y: 0.8)
|
|
1454
|
+
}) { _ in
|
|
1455
|
+
indicator.removeFromSuperview()
|
|
1456
|
+
if self.focusIndicatorView == indicator {
|
|
1457
|
+
self.focusIndicatorView = nil
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1112
1464
|
@objc
|
|
1113
1465
|
private func handlePinch(_ pinch: UIPinchGestureRecognizer) {
|
|
1114
1466
|
guard let device = self.currentCameraPosition == .rear ? rearCamera : frontCamera else { return }
|
|
@@ -1223,6 +1575,7 @@ enum CameraControllerError: Swift.Error {
|
|
|
1223
1575
|
case cannotFindDocumentsDirectory
|
|
1224
1576
|
case fileVideoOutputNotFound
|
|
1225
1577
|
case unknown
|
|
1578
|
+
case invalidZoomLevel(min: CGFloat, max: CGFloat, requested: CGFloat)
|
|
1226
1579
|
}
|
|
1227
1580
|
|
|
1228
1581
|
public enum CameraPosition {
|
|
@@ -1249,6 +1602,8 @@ extension CameraControllerError: LocalizedError {
|
|
|
1249
1602
|
return NSLocalizedString("Cannot find documents directory", comment: "This should never happen")
|
|
1250
1603
|
case .fileVideoOutputNotFound:
|
|
1251
1604
|
return NSLocalizedString("Video recording is not available. Make sure the camera is properly initialized.", comment: "Video recording not available")
|
|
1605
|
+
case .invalidZoomLevel(let min, let max, let requested):
|
|
1606
|
+
return NSLocalizedString("Invalid zoom level. Must be between \(min) and \(max). Requested: \(requested)", comment: "Invalid Zoom Level")
|
|
1252
1607
|
}
|
|
1253
1608
|
}
|
|
1254
1609
|
}
|
|
@@ -1312,9 +1667,13 @@ extension UIImage {
|
|
|
1312
1667
|
|
|
1313
1668
|
switch imageOrientation {
|
|
1314
1669
|
case .left, .leftMirrored, .right, .rightMirrored:
|
|
1315
|
-
|
|
1670
|
+
if let cgImage = self.cgImage {
|
|
1671
|
+
ctx.draw(cgImage, in: CGRect(x: 0, y: 0, width: size.height, height: size.width))
|
|
1672
|
+
}
|
|
1316
1673
|
default:
|
|
1317
|
-
|
|
1674
|
+
if let cgImage = self.cgImage {
|
|
1675
|
+
ctx.draw(cgImage, in: CGRect(x: 0, y: 0, width: size.width, height: size.height))
|
|
1676
|
+
}
|
|
1318
1677
|
}
|
|
1319
1678
|
guard let newCGImage = ctx.makeImage() else { return nil }
|
|
1320
1679
|
return UIImage.init(cgImage: newCGImage, scale: 1, orientation: .up)
|