@capgo/camera-preview 7.4.0-beta.8 → 7.4.0
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 +243 -51
- package/android/gradle/wrapper/gradle-wrapper.properties +1 -1
- package/android/src/main/java/com/ahm/capacitor/camera/preview/CameraPreview.java +1249 -143
- package/android/src/main/java/com/ahm/capacitor/camera/preview/CameraXView.java +3400 -1382
- package/android/src/main/java/com/ahm/capacitor/camera/preview/GridOverlayView.java +95 -58
- 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 +160 -72
- 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 +441 -40
- package/dist/esm/definitions.d.ts +167 -25
- package/dist/esm/definitions.js.map +1 -1
- package/dist/esm/index.d.ts +2 -0
- package/dist/esm/index.js +24 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/web.d.ts +23 -3
- package/dist/esm/web.js +463 -65
- package/dist/esm/web.js.map +1 -1
- package/dist/plugin.cjs.js +485 -64
- package/dist/plugin.cjs.js.map +1 -1
- package/dist/plugin.js +485 -64
- package/dist/plugin.js.map +1 -1
- package/ios/Sources/{CapgoCameraPreview → CapgoCameraPreviewPlugin}/CameraController.swift +731 -315
- package/ios/Sources/CapgoCameraPreviewPlugin/Plugin.swift +1902 -0
- package/package.json +11 -3
- 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/fileChanges/last-build.bin +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/8.14.2/gc.properties +0 -0
- package/android/.gradle/buildOutputCleanup/buildOutputCleanup.lock +0 -0
- package/android/.gradle/buildOutputCleanup/cache.properties +0 -2
- package/android/.gradle/buildOutputCleanup/outputFiles.bin +0 -0
- package/android/.gradle/file-system.probe +0 -0
- package/android/.gradle/vcs-1/gc.properties +0 -0
- package/ios/Sources/CapgoCameraPreview/Plugin.swift +0 -1211
- /package/ios/Sources/{CapgoCameraPreview → CapgoCameraPreviewPlugin}/GridOverlayView.swift +0 -0
|
@@ -1,11 +1,3 @@
|
|
|
1
|
-
//
|
|
2
|
-
// CameraController.swift
|
|
3
|
-
// Plugin
|
|
4
|
-
//
|
|
5
|
-
// Created by Ariel Hernandez Musa on 7/14/19.
|
|
6
|
-
// Copyright © 2019 Max Lynch. All rights reserved.
|
|
7
|
-
//
|
|
8
|
-
|
|
9
1
|
import AVFoundation
|
|
10
2
|
import UIKit
|
|
11
3
|
import CoreLocation
|
|
@@ -24,46 +16,78 @@ class CameraController: NSObject {
|
|
|
24
16
|
var rearCamera: AVCaptureDevice?
|
|
25
17
|
var rearCameraInput: AVCaptureDeviceInput?
|
|
26
18
|
|
|
19
|
+
var allDiscoveredDevices: [AVCaptureDevice] = []
|
|
20
|
+
|
|
27
21
|
var fileVideoOutput: AVCaptureMovieFileOutput?
|
|
28
22
|
|
|
29
23
|
var previewLayer: AVCaptureVideoPreviewLayer?
|
|
30
24
|
var gridOverlayView: GridOverlayView?
|
|
25
|
+
var focusIndicatorView: UIView?
|
|
31
26
|
|
|
32
27
|
var flashMode = AVCaptureDevice.FlashMode.off
|
|
33
|
-
var photoCaptureCompletionBlock: ((UIImage?, Error?) -> Void)?
|
|
28
|
+
var photoCaptureCompletionBlock: ((UIImage?, Data?, [AnyHashable: Any]?, Error?) -> Void)?
|
|
34
29
|
|
|
35
30
|
var sampleBufferCaptureCompletionBlock: ((UIImage?, Error?) -> Void)?
|
|
36
31
|
|
|
37
|
-
|
|
32
|
+
// Add callback for detecting when first frame is ready
|
|
33
|
+
var firstFrameReadyCallback: (() -> Void)?
|
|
34
|
+
var hasReceivedFirstFrame = false
|
|
38
35
|
|
|
39
36
|
var audioDevice: AVCaptureDevice?
|
|
40
37
|
var audioInput: AVCaptureDeviceInput?
|
|
41
38
|
|
|
42
|
-
var zoomFactor: CGFloat =
|
|
39
|
+
var zoomFactor: CGFloat = 2.0
|
|
43
40
|
private var lastZoomUpdateTime: TimeInterval = 0
|
|
44
41
|
private let zoomUpdateThrottle: TimeInterval = 1.0 / 60.0 // 60 FPS max
|
|
45
42
|
|
|
46
43
|
var videoFileURL: URL?
|
|
47
44
|
private let saneMaxZoomFactor: CGFloat = 25.5
|
|
48
45
|
|
|
46
|
+
// Track output preparation status
|
|
47
|
+
private var outputsPrepared: Bool = false
|
|
48
|
+
|
|
49
49
|
var isUsingMultiLensVirtualCamera: Bool {
|
|
50
50
|
guard let device = (currentCameraPosition == .rear) ? rearCamera : frontCamera else { return false }
|
|
51
51
|
// A rear multi-lens virtual camera will have a min zoom of 1.0 but support wider angles
|
|
52
52
|
return device.position == .back && device.isVirtualDevice && device.constituentDevices.count > 1
|
|
53
53
|
}
|
|
54
|
+
|
|
55
|
+
// Returns the display zoom multiplier introduced in iOS 18 to map between
|
|
56
|
+
// native zoom factor and the UI-displayed zoom factor. Falls back to 1.0 on
|
|
57
|
+
// older systems or if the property is unavailable.
|
|
58
|
+
func getDisplayZoomMultiplier() -> Float {
|
|
59
|
+
var multiplier: Float = 1.0
|
|
60
|
+
// Use KVC to avoid compile-time dependency on the iOS 18 SDK symbol
|
|
61
|
+
let device = (currentCameraPosition == .rear) ? rearCamera : frontCamera
|
|
62
|
+
if #available(iOS 18.0, *), let device = device {
|
|
63
|
+
if let value = device.value(forKey: "displayVideoZoomFactorMultiplier") as? NSNumber {
|
|
64
|
+
let m = value.floatValue
|
|
65
|
+
if m > 0 { multiplier = m }
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return multiplier
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Track whether an aspect ratio was explicitly requested
|
|
72
|
+
var requestedAspectRatio: String?
|
|
54
73
|
}
|
|
55
74
|
|
|
56
75
|
extension CameraController {
|
|
57
|
-
func
|
|
58
|
-
//
|
|
76
|
+
func prepareFullSession() {
|
|
77
|
+
// This function is now deprecated in favor of inline session creation in prepare()
|
|
78
|
+
// Kept for backward compatibility
|
|
59
79
|
guard self.captureSession == nil else { return }
|
|
60
|
-
|
|
61
|
-
print("[CameraPreview] Preparing basic camera session in background")
|
|
62
|
-
|
|
63
|
-
// Create basic capture session
|
|
80
|
+
|
|
64
81
|
self.captureSession = AVCaptureSession()
|
|
65
|
-
|
|
66
|
-
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private func ensureCamerasDiscovered() {
|
|
85
|
+
// Rediscover cameras if the array is empty OR if the camera pointers are nil
|
|
86
|
+
guard allDiscoveredDevices.isEmpty || (rearCamera == nil && frontCamera == nil) else { return }
|
|
87
|
+
discoverAndConfigureCameras()
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private func discoverAndConfigureCameras() {
|
|
67
91
|
let deviceTypes: [AVCaptureDevice.DeviceType] = [
|
|
68
92
|
.builtInWideAngleCamera,
|
|
69
93
|
.builtInUltraWideCamera,
|
|
@@ -76,327 +100,331 @@ extension CameraController {
|
|
|
76
100
|
|
|
77
101
|
let session = AVCaptureDevice.DiscoverySession(deviceTypes: deviceTypes, mediaType: AVMediaType.video, position: .unspecified)
|
|
78
102
|
let cameras = session.devices.compactMap { $0 }
|
|
79
|
-
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
103
|
+
|
|
104
|
+
// Store all discovered devices for fast lookup later
|
|
105
|
+
self.allDiscoveredDevices = cameras
|
|
106
|
+
|
|
107
|
+
// Log all found devices for debugging
|
|
108
|
+
|
|
109
|
+
for camera in cameras {
|
|
110
|
+
let constituentCount = camera.isVirtualDevice ? camera.constituentDevices.count : 1
|
|
111
|
+
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Set front camera (usually just one option)
|
|
84
115
|
self.frontCamera = cameras.first(where: { $0.position == .front })
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
116
|
+
|
|
117
|
+
// Find rear camera - prefer tripleCamera for multi-lens support
|
|
118
|
+
let rearCameras = cameras.filter { $0.position == .back }
|
|
119
|
+
|
|
120
|
+
// First try to find built-in triple camera (provides access to all lenses)
|
|
121
|
+
if let tripleCamera = rearCameras.first(where: {
|
|
122
|
+
$0.deviceType == .builtInTripleCamera
|
|
123
|
+
}) {
|
|
124
|
+
self.rearCamera = tripleCamera
|
|
125
|
+
} else if let dualWideCamera = rearCameras.first(where: {
|
|
126
|
+
$0.deviceType == .builtInDualWideCamera
|
|
127
|
+
}) {
|
|
128
|
+
// Fallback to dual wide camera
|
|
129
|
+
self.rearCamera = dualWideCamera
|
|
130
|
+
} else if let dualCamera = rearCameras.first(where: {
|
|
131
|
+
$0.deviceType == .builtInDualCamera
|
|
132
|
+
}) {
|
|
133
|
+
// Fallback to dual camera
|
|
134
|
+
self.rearCamera = dualCamera
|
|
135
|
+
} else if let wideAngleCamera = rearCameras.first(where: {
|
|
136
|
+
$0.deviceType == .builtInWideAngleCamera
|
|
137
|
+
}) {
|
|
138
|
+
// Fallback to wide angle camera
|
|
139
|
+
self.rearCamera = wideAngleCamera
|
|
140
|
+
} else if let firstRearCamera = rearCameras.first {
|
|
141
|
+
// Final fallback to any rear camera
|
|
89
142
|
self.rearCamera = firstRearCamera
|
|
90
143
|
}
|
|
91
|
-
|
|
92
|
-
|
|
144
|
+
|
|
145
|
+
// Pre-configure focus modes
|
|
146
|
+
configureCameraFocus(camera: self.rearCamera)
|
|
147
|
+
configureCameraFocus(camera: self.frontCamera)
|
|
93
148
|
}
|
|
94
149
|
|
|
95
|
-
func
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
150
|
+
private func configureCameraFocus(camera: AVCaptureDevice?) {
|
|
151
|
+
guard let camera = camera else { return }
|
|
152
|
+
|
|
153
|
+
do {
|
|
154
|
+
try camera.lockForConfiguration()
|
|
155
|
+
if camera.isFocusModeSupported(.continuousAutoFocus) {
|
|
156
|
+
camera.focusMode = .continuousAutoFocus
|
|
100
157
|
}
|
|
158
|
+
camera.unlockForConfiguration()
|
|
159
|
+
} catch {
|
|
160
|
+
print("[CameraPreview] Could not configure focus for \(camera.localizedName): \(error)")
|
|
101
161
|
}
|
|
162
|
+
}
|
|
102
163
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
print("[CameraPreview] Using pre-discovered cameras")
|
|
107
|
-
return
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// Expanded device types to support more camera configurations
|
|
111
|
-
let deviceTypes: [AVCaptureDevice.DeviceType] = [
|
|
112
|
-
.builtInWideAngleCamera,
|
|
113
|
-
.builtInUltraWideCamera,
|
|
114
|
-
.builtInTelephotoCamera,
|
|
115
|
-
.builtInDualCamera,
|
|
116
|
-
.builtInDualWideCamera,
|
|
117
|
-
.builtInTripleCamera,
|
|
118
|
-
.builtInTrueDepthCamera
|
|
119
|
-
]
|
|
120
|
-
|
|
121
|
-
let session = AVCaptureDevice.DiscoverySession(deviceTypes: deviceTypes, mediaType: AVMediaType.video, position: .unspecified)
|
|
122
|
-
|
|
123
|
-
let cameras = session.devices.compactMap { $0 }
|
|
124
|
-
|
|
125
|
-
// Log all found devices for debugging
|
|
126
|
-
print("[CameraPreview] Found \(cameras.count) devices:")
|
|
127
|
-
for camera in cameras {
|
|
128
|
-
let constituentCount = camera.isVirtualDevice ? camera.constituentDevices.count : 1
|
|
129
|
-
print("[CameraPreview] - \(camera.localizedName) (Position: \(camera.position.rawValue), Virtual: \(camera.isVirtualDevice), Lenses: \(constituentCount), Zoom: \(camera.minAvailableVideoZoomFactor)-\(camera.maxAvailableVideoZoomFactor))")
|
|
130
|
-
}
|
|
164
|
+
private func prepareOutputs() {
|
|
165
|
+
// Skip if already prepared
|
|
166
|
+
guard !self.outputsPrepared else { return }
|
|
131
167
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
}
|
|
168
|
+
// Create photo output
|
|
169
|
+
self.photoOutput = AVCapturePhotoOutput()
|
|
170
|
+
self.photoOutput?.isHighResolutionCaptureEnabled = true
|
|
136
171
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
let rearVirtualDevices = cameras.filter { $0.position == .back && $0.isVirtualDevice }
|
|
140
|
-
let bestRearVirtualDevice = rearVirtualDevices.max { $0.constituentDevices.count < $1.constituentDevices.count }
|
|
172
|
+
// Create video output
|
|
173
|
+
self.fileVideoOutput = AVCaptureMovieFileOutput()
|
|
141
174
|
|
|
142
|
-
|
|
175
|
+
// Create data output for preview
|
|
176
|
+
self.dataOutput = AVCaptureVideoDataOutput()
|
|
177
|
+
self.dataOutput?.videoSettings = [
|
|
178
|
+
(kCVPixelBufferPixelFormatTypeKey as String): NSNumber(value: kCVPixelFormatType_32BGRA as UInt32)
|
|
179
|
+
]
|
|
180
|
+
self.dataOutput?.alwaysDiscardsLateVideoFrames = true
|
|
143
181
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
// Fallback for devices without a virtual camera system
|
|
149
|
-
self.rearCamera = firstRearCamera
|
|
150
|
-
print("[CameraPreview] WARN: No virtual rear camera found. Selected first available: \(firstRearCamera.localizedName)")
|
|
151
|
-
}
|
|
152
|
-
// --- End of Correction ---
|
|
182
|
+
// Pre-create preview layer to avoid delay later
|
|
183
|
+
if self.previewLayer == nil {
|
|
184
|
+
self.previewLayer = AVCaptureVideoPreviewLayer()
|
|
185
|
+
}
|
|
153
186
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
187
|
+
// Mark as prepared
|
|
188
|
+
self.outputsPrepared = true
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
func prepare(cameraPosition: String, deviceId: String? = nil, disableAudio: Bool, cameraMode: Bool, aspectRatio: String? = nil, initialZoomLevel: Float?, completionHandler: @escaping (Error?) -> Void) {
|
|
192
|
+
print("[CameraPreview] 🎬 Starting prepare - position: \(cameraPosition), deviceId: \(deviceId ?? "nil"), disableAudio: \(disableAudio), cameraMode: \(cameraMode), aspectRatio: \(aspectRatio ?? "nil"), zoom: \(initialZoomLevel)")
|
|
193
|
+
|
|
194
|
+
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
|
195
|
+
guard let self = self else {
|
|
196
|
+
DispatchQueue.main.async {
|
|
197
|
+
completionHandler(CameraControllerError.unknown)
|
|
163
198
|
}
|
|
199
|
+
return
|
|
164
200
|
}
|
|
165
201
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
202
|
+
do {
|
|
203
|
+
// Create session if needed
|
|
204
|
+
if self.captureSession == nil {
|
|
205
|
+
self.captureSession = AVCaptureSession()
|
|
206
|
+
}
|
|
170
207
|
|
|
171
|
-
|
|
172
|
-
|
|
208
|
+
guard let captureSession = self.captureSession else {
|
|
209
|
+
throw CameraControllerError.captureSessionIsMissing
|
|
210
|
+
}
|
|
173
211
|
|
|
174
|
-
|
|
212
|
+
// Prepare outputs
|
|
213
|
+
self.prepareOutputs()
|
|
175
214
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
let allDevices = AVCaptureDevice.DiscoverySession(
|
|
179
|
-
deviceTypes: [.builtInWideAngleCamera, .builtInUltraWideCamera, .builtInTelephotoCamera, .builtInDualCamera, .builtInDualWideCamera, .builtInTripleCamera, .builtInTrueDepthCamera],
|
|
180
|
-
mediaType: .video,
|
|
181
|
-
position: .unspecified
|
|
182
|
-
).devices
|
|
215
|
+
// Configure the session
|
|
216
|
+
captureSession.beginConfiguration()
|
|
183
217
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
218
|
+
// Set aspect ratio preset and remember requested ratio
|
|
219
|
+
self.requestedAspectRatio = aspectRatio
|
|
220
|
+
self.configureSessionPreset(for: aspectRatio)
|
|
221
|
+
|
|
222
|
+
// Configure device inputs
|
|
223
|
+
try self.configureDeviceInputs(cameraPosition: cameraPosition, deviceId: deviceId, disableAudio: disableAudio)
|
|
224
|
+
|
|
225
|
+
// Add data output BEFORE starting session for faster first frame
|
|
226
|
+
if let dataOutput = self.dataOutput, captureSession.canAddOutput(dataOutput) {
|
|
227
|
+
captureSession.addOutput(dataOutput)
|
|
228
|
+
dataOutput.setSampleBufferDelegate(self, queue: DispatchQueue.main)
|
|
194
229
|
}
|
|
195
|
-
}
|
|
196
230
|
|
|
197
|
-
|
|
198
|
-
print("[CameraPreview] ERROR: No camera device selected for position: \(cameraPosition)")
|
|
199
|
-
throw CameraControllerError.noCamerasAvailable
|
|
200
|
-
}
|
|
231
|
+
captureSession.commitConfiguration()
|
|
201
232
|
|
|
202
|
-
|
|
203
|
-
|
|
233
|
+
// Set initial zoom
|
|
234
|
+
self.setInitialZoom(level: initialZoomLevel)
|
|
204
235
|
|
|
205
|
-
|
|
206
|
-
captureSession.
|
|
236
|
+
// Start the session
|
|
237
|
+
captureSession.startRunning()
|
|
207
238
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
self
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
if finalDevice.isFocusModeSupported(.continuousAutoFocus) {
|
|
220
|
-
finalDevice.focusMode = .continuousAutoFocus
|
|
239
|
+
// Defer adding photo/video outputs to avoid blocking
|
|
240
|
+
// These aren't needed immediately for preview
|
|
241
|
+
DispatchQueue.global(qos: .utility).async { [weak self] in
|
|
242
|
+
guard let self = self else { return }
|
|
243
|
+
|
|
244
|
+
captureSession.beginConfiguration()
|
|
245
|
+
|
|
246
|
+
// Add photo output
|
|
247
|
+
if let photoOutput = self.photoOutput, captureSession.canAddOutput(photoOutput) {
|
|
248
|
+
photoOutput.isHighResolutionCaptureEnabled = true
|
|
249
|
+
captureSession.addOutput(photoOutput)
|
|
221
250
|
}
|
|
222
251
|
|
|
223
|
-
//
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
if finalDevice.isVirtualDevice && finalDevice.constituentDevices.count > 1 && finalDevice.videoZoomFactor != defaultWideAngleZoom {
|
|
227
|
-
// Check if 2.0 is a valid zoom factor before setting it.
|
|
228
|
-
if defaultWideAngleZoom >= finalDevice.minAvailableVideoZoomFactor && defaultWideAngleZoom <= finalDevice.maxAvailableVideoZoomFactor {
|
|
229
|
-
print("[CameraPreview] Multi-camera system detected. Setting initial zoom to \(defaultWideAngleZoom) (standard wide-angle).")
|
|
230
|
-
finalDevice.videoZoomFactor = defaultWideAngleZoom
|
|
231
|
-
}
|
|
252
|
+
// Add video output if needed
|
|
253
|
+
if cameraMode, let fileVideoOutput = self.fileVideoOutput, captureSession.canAddOutput(fileVideoOutput) {
|
|
254
|
+
captureSession.addOutput(fileVideoOutput)
|
|
232
255
|
}
|
|
233
|
-
|
|
234
|
-
|
|
256
|
+
|
|
257
|
+
captureSession.commitConfiguration()
|
|
235
258
|
}
|
|
236
|
-
} else {
|
|
237
|
-
print("[CameraPreview] ERROR: Cannot add device input to session.")
|
|
238
|
-
throw CameraControllerError.inputsAreInvalid
|
|
239
|
-
}
|
|
240
259
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
throw CameraControllerError.inputsAreInvalid
|
|
249
|
-
}
|
|
260
|
+
// Success callback
|
|
261
|
+
DispatchQueue.main.async {
|
|
262
|
+
completionHandler(nil)
|
|
263
|
+
}
|
|
264
|
+
} catch {
|
|
265
|
+
DispatchQueue.main.async {
|
|
266
|
+
completionHandler(error)
|
|
250
267
|
}
|
|
251
268
|
}
|
|
252
269
|
}
|
|
270
|
+
}
|
|
253
271
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
if captureSession.canSetSessionPreset(.hd4K3840x2160) {
|
|
266
|
-
targetPreset = .hd4K3840x2160
|
|
267
|
-
} else if captureSession.canSetSessionPreset(.hd1920x1080) {
|
|
268
|
-
targetPreset = .hd1920x1080
|
|
269
|
-
} else if captureSession.canSetSessionPreset(.hd1280x720) {
|
|
270
|
-
targetPreset = .hd1280x720
|
|
271
|
-
} else {
|
|
272
|
-
targetPreset = .high
|
|
273
|
-
}
|
|
274
|
-
case "4:3":
|
|
275
|
-
// Use photo preset for 4:3 aspect ratio (highest quality)
|
|
276
|
-
if captureSession.canSetSessionPreset(.photo) {
|
|
277
|
-
targetPreset = .photo
|
|
278
|
-
} else if captureSession.canSetSessionPreset(.high) {
|
|
279
|
-
targetPreset = .high
|
|
280
|
-
} else {
|
|
281
|
-
targetPreset = .medium
|
|
282
|
-
}
|
|
283
|
-
default:
|
|
284
|
-
// Default to highest available quality
|
|
285
|
-
if captureSession.canSetSessionPreset(.photo) {
|
|
286
|
-
targetPreset = .photo
|
|
287
|
-
} else if captureSession.canSetSessionPreset(.high) {
|
|
288
|
-
targetPreset = .high
|
|
289
|
-
} else {
|
|
290
|
-
targetPreset = .medium
|
|
291
|
-
}
|
|
272
|
+
private func configureSessionPreset(for aspectRatio: String?) {
|
|
273
|
+
guard let captureSession = self.captureSession else { return }
|
|
274
|
+
|
|
275
|
+
var targetPreset: AVCaptureSession.Preset = .photo
|
|
276
|
+
if let aspectRatio = aspectRatio {
|
|
277
|
+
switch aspectRatio {
|
|
278
|
+
case "16:9":
|
|
279
|
+
if captureSession.canSetSessionPreset(.hd4K3840x2160) {
|
|
280
|
+
targetPreset = .hd4K3840x2160
|
|
281
|
+
} else if captureSession.canSetSessionPreset(.hd1920x1080) {
|
|
282
|
+
targetPreset = .hd1920x1080
|
|
292
283
|
}
|
|
293
|
-
|
|
294
|
-
// Default to highest available quality when no aspect ratio specified
|
|
284
|
+
case "4:3":
|
|
295
285
|
if captureSession.canSetSessionPreset(.photo) {
|
|
296
286
|
targetPreset = .photo
|
|
297
287
|
} else if captureSession.canSetSessionPreset(.high) {
|
|
298
288
|
targetPreset = .high
|
|
299
289
|
} else {
|
|
300
|
-
targetPreset = .
|
|
290
|
+
targetPreset = captureSession.sessionPreset
|
|
301
291
|
}
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
// Fallback to high quality preset if the target preset is not supported
|
|
310
|
-
print("[CameraPreview] Target preset \(targetPreset) not supported, falling back to .high")
|
|
311
|
-
if captureSession.canSetSessionPreset(.high) {
|
|
312
|
-
captureSession.sessionPreset = .high
|
|
313
|
-
} else if captureSession.canSetSessionPreset(.medium) {
|
|
314
|
-
captureSession.sessionPreset = .medium
|
|
292
|
+
default:
|
|
293
|
+
if captureSession.canSetSessionPreset(.photo) {
|
|
294
|
+
targetPreset = .photo
|
|
295
|
+
} else if captureSession.canSetSessionPreset(.high) {
|
|
296
|
+
targetPreset = .high
|
|
297
|
+
} else {
|
|
298
|
+
targetPreset = captureSession.sessionPreset
|
|
315
299
|
}
|
|
316
300
|
}
|
|
301
|
+
}
|
|
317
302
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
303
|
+
if captureSession.canSetSessionPreset(targetPreset) {
|
|
304
|
+
captureSession.sessionPreset = targetPreset
|
|
305
|
+
}
|
|
306
|
+
}
|
|
322
307
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
captureSession.startRunning()
|
|
308
|
+
private func setInitialZoom(level: Float?) {
|
|
309
|
+
let device = (currentCameraPosition == .rear) ? rearCamera : frontCamera
|
|
310
|
+
guard let device = device else {
|
|
311
|
+
print("[CameraPreview] No device available for initial zoom")
|
|
312
|
+
return
|
|
329
313
|
}
|
|
330
314
|
|
|
331
|
-
|
|
332
|
-
|
|
315
|
+
let minZoom = device.minAvailableVideoZoomFactor
|
|
316
|
+
let maxZoom = min(device.maxAvailableVideoZoomFactor, saneMaxZoomFactor)
|
|
333
317
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
captureSession.addOutput(self.dataOutput!)
|
|
341
|
-
}
|
|
318
|
+
// Compute UI-level default = 1 * multiplier when not provided
|
|
319
|
+
let multiplier = self.getDisplayZoomMultiplier()
|
|
320
|
+
// if level is nil, it's the initial zoom
|
|
321
|
+
let uiLevel: Float = level ?? (2.0 * multiplier)
|
|
322
|
+
// Map UI/display zoom to native zoom using iOS 18+ multiplier
|
|
323
|
+
let adjustedLevel = multiplier != 1.0 ? (uiLevel / multiplier) : uiLevel
|
|
342
324
|
|
|
343
|
-
|
|
325
|
+
guard CGFloat(adjustedLevel) >= minZoom && CGFloat(adjustedLevel) <= maxZoom else {
|
|
326
|
+
print("[CameraPreview] Initial zoom level \(adjustedLevel) out of range (\(minZoom)-\(maxZoom))")
|
|
327
|
+
return
|
|
328
|
+
}
|
|
344
329
|
|
|
345
|
-
|
|
346
|
-
|
|
330
|
+
do {
|
|
331
|
+
try device.lockForConfiguration()
|
|
332
|
+
device.videoZoomFactor = CGFloat(adjustedLevel)
|
|
333
|
+
device.unlockForConfiguration()
|
|
334
|
+
self.zoomFactor = CGFloat(adjustedLevel)
|
|
335
|
+
} catch {
|
|
336
|
+
print("[CameraPreview] Failed to set initial zoom: \(error)")
|
|
347
337
|
}
|
|
338
|
+
}
|
|
348
339
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
createCaptureSession()
|
|
352
|
-
try configureCaptureDevices()
|
|
353
|
-
try configureDeviceInputs()
|
|
354
|
-
try configurePhotoOutput(cameraMode: cameraMode)
|
|
355
|
-
try configureDataOutput()
|
|
356
|
-
// try configureVideoOutput()
|
|
357
|
-
} catch {
|
|
358
|
-
DispatchQueue.main.async {
|
|
359
|
-
completionHandler(error)
|
|
360
|
-
}
|
|
340
|
+
private func configureDeviceInputs(cameraPosition: String, deviceId: String?, disableAudio: Bool) throws {
|
|
341
|
+
guard let captureSession = self.captureSession else { throw CameraControllerError.captureSessionIsMissing }
|
|
361
342
|
|
|
362
|
-
|
|
363
|
-
|
|
343
|
+
// Ensure cameras are discovered before configuring inputs
|
|
344
|
+
ensureCamerasDiscovered()
|
|
364
345
|
|
|
365
|
-
|
|
366
|
-
|
|
346
|
+
var selectedDevice: AVCaptureDevice?
|
|
347
|
+
|
|
348
|
+
// If deviceId is specified, find that specific device from discovered devices
|
|
349
|
+
if let deviceId = deviceId {
|
|
350
|
+
selectedDevice = self.allDiscoveredDevices.first(where: { $0.uniqueID == deviceId })
|
|
351
|
+
guard selectedDevice != nil else {
|
|
352
|
+
throw CameraControllerError.noCamerasAvailable
|
|
353
|
+
}
|
|
354
|
+
} else {
|
|
355
|
+
// Use position-based selection from discovered cameras
|
|
356
|
+
if cameraPosition == "rear" {
|
|
357
|
+
selectedDevice = self.rearCamera
|
|
358
|
+
} else if cameraPosition == "front" {
|
|
359
|
+
selectedDevice = self.frontCamera
|
|
367
360
|
}
|
|
368
361
|
}
|
|
369
|
-
}
|
|
370
362
|
|
|
371
|
-
|
|
372
|
-
|
|
363
|
+
guard let finalDevice = selectedDevice else {
|
|
364
|
+
throw CameraControllerError.noCamerasAvailable
|
|
365
|
+
}
|
|
373
366
|
|
|
374
|
-
|
|
367
|
+
let deviceInput = try AVCaptureDeviceInput(device: finalDevice)
|
|
375
368
|
|
|
376
|
-
|
|
377
|
-
|
|
369
|
+
if captureSession.canAddInput(deviceInput) {
|
|
370
|
+
captureSession.addInput(deviceInput)
|
|
378
371
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
372
|
+
if finalDevice.position == .front {
|
|
373
|
+
self.frontCameraInput = deviceInput
|
|
374
|
+
self.currentCameraPosition = .front
|
|
375
|
+
} else {
|
|
376
|
+
self.rearCameraInput = deviceInput
|
|
377
|
+
self.currentCameraPosition = .rear
|
|
378
|
+
}
|
|
379
|
+
} else {
|
|
380
|
+
throw CameraControllerError.inputsAreInvalid
|
|
381
|
+
}
|
|
382
382
|
|
|
383
|
-
//
|
|
384
|
-
if
|
|
385
|
-
self.
|
|
383
|
+
// Add audio input if needed
|
|
384
|
+
if !disableAudio {
|
|
385
|
+
if self.audioDevice == nil {
|
|
386
|
+
self.audioDevice = AVCaptureDevice.default(for: AVMediaType.audio)
|
|
387
|
+
}
|
|
388
|
+
if let audioDevice = self.audioDevice {
|
|
389
|
+
self.audioInput = try AVCaptureDeviceInput(device: audioDevice)
|
|
390
|
+
if captureSession.canAddInput(self.audioInput!) {
|
|
391
|
+
captureSession.addInput(self.audioInput!)
|
|
392
|
+
} else {
|
|
393
|
+
throw CameraControllerError.inputsAreInvalid
|
|
394
|
+
}
|
|
395
|
+
}
|
|
386
396
|
}
|
|
397
|
+
}
|
|
387
398
|
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
CATransaction.setDisableActions(true)
|
|
393
|
-
self.previewLayer?.frame = view.bounds
|
|
394
|
-
CATransaction.commit()
|
|
399
|
+
func displayPreview(on view: UIView) throws {
|
|
400
|
+
guard let captureSession = self.captureSession, captureSession.isRunning else {
|
|
401
|
+
throw CameraControllerError.captureSessionIsMissing
|
|
402
|
+
}
|
|
395
403
|
|
|
396
|
-
|
|
397
|
-
|
|
404
|
+
// Create or reuse preview layer
|
|
405
|
+
let previewLayer: AVCaptureVideoPreviewLayer
|
|
406
|
+
if let existingLayer = self.previewLayer {
|
|
407
|
+
// Always reuse if we have one - just update the session if needed
|
|
408
|
+
previewLayer = existingLayer
|
|
409
|
+
if existingLayer.session != captureSession {
|
|
410
|
+
existingLayer.session = captureSession
|
|
411
|
+
}
|
|
412
|
+
} else {
|
|
413
|
+
// Create layer with minimal properties to speed up creation
|
|
414
|
+
previewLayer = AVCaptureVideoPreviewLayer()
|
|
415
|
+
previewLayer.session = captureSession
|
|
416
|
+
}
|
|
398
417
|
|
|
399
|
-
|
|
418
|
+
// Fast configuration without CATransaction overhead
|
|
419
|
+
// Use resizeAspect to avoid crop when no aspect ratio is requested; otherwise fill
|
|
420
|
+
previewLayer.videoGravity = (requestedAspectRatio == nil) ? .resizeAspect : .resizeAspectFill
|
|
421
|
+
previewLayer.frame = view.bounds
|
|
422
|
+
|
|
423
|
+
// Insert layer immediately (only if new)
|
|
424
|
+
if previewLayer.superlayer != view.layer {
|
|
425
|
+
view.layer.insertSublayer(previewLayer, at: 0)
|
|
426
|
+
}
|
|
427
|
+
self.previewLayer = previewLayer
|
|
400
428
|
}
|
|
401
429
|
|
|
402
430
|
func addGridOverlay(to view: UIView, gridMode: String) {
|
|
@@ -443,8 +471,8 @@ extension CameraController {
|
|
|
443
471
|
if Thread.isMainThread {
|
|
444
472
|
updateVideoOrientationOnMainThread()
|
|
445
473
|
} else {
|
|
446
|
-
DispatchQueue.main.
|
|
447
|
-
self
|
|
474
|
+
DispatchQueue.main.sync {
|
|
475
|
+
self.updateVideoOrientationOnMainThread()
|
|
448
476
|
}
|
|
449
477
|
}
|
|
450
478
|
}
|
|
@@ -477,6 +505,33 @@ extension CameraController {
|
|
|
477
505
|
photoOutput?.connections.forEach { $0.videoOrientation = videoOrientation }
|
|
478
506
|
}
|
|
479
507
|
|
|
508
|
+
private func setDefaultZoomAfterFlip() {
|
|
509
|
+
let device = (currentCameraPosition == .rear) ? rearCamera : frontCamera
|
|
510
|
+
guard let device = device else {
|
|
511
|
+
print("[CameraPreview] No device available for default zoom after flip")
|
|
512
|
+
return
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Set zoom to 1.0x in UI terms, accounting for display multiplier
|
|
516
|
+
let multiplier = self.getDisplayZoomMultiplier()
|
|
517
|
+
let targetUIZoom: Float = 1.0 // We want 1.0x in the UI
|
|
518
|
+
let nativeZoom = multiplier != 1.0 ? (targetUIZoom / multiplier) : targetUIZoom
|
|
519
|
+
|
|
520
|
+
let minZoom = device.minAvailableVideoZoomFactor
|
|
521
|
+
let maxZoom = min(device.maxAvailableVideoZoomFactor, saneMaxZoomFactor)
|
|
522
|
+
let clampedZoom = max(minZoom, min(CGFloat(nativeZoom), maxZoom))
|
|
523
|
+
|
|
524
|
+
do {
|
|
525
|
+
try device.lockForConfiguration()
|
|
526
|
+
device.videoZoomFactor = clampedZoom
|
|
527
|
+
device.unlockForConfiguration()
|
|
528
|
+
self.zoomFactor = clampedZoom
|
|
529
|
+
print("[CameraPreview] Set default zoom after flip: UI=\(targetUIZoom)x, native=\(clampedZoom), multiplier=\(multiplier)")
|
|
530
|
+
} catch {
|
|
531
|
+
print("[CameraPreview] Failed to set default zoom after flip: \(error)")
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
480
535
|
func switchCameras() throws {
|
|
481
536
|
guard let currentCameraPosition = currentCameraPosition,
|
|
482
537
|
let captureSession = self.captureSession else {
|
|
@@ -485,7 +540,7 @@ extension CameraController {
|
|
|
485
540
|
|
|
486
541
|
// Ensure we have the necessary cameras
|
|
487
542
|
guard (currentCameraPosition == .front && rearCamera != nil) ||
|
|
488
|
-
|
|
543
|
+
(currentCameraPosition == .rear && frontCamera != nil) else {
|
|
489
544
|
throw CameraControllerError.noCamerasAvailable
|
|
490
545
|
}
|
|
491
546
|
|
|
@@ -501,9 +556,7 @@ extension CameraController {
|
|
|
501
556
|
captureSession.commitConfiguration()
|
|
502
557
|
// Restart the session if it was running before
|
|
503
558
|
if wasRunning {
|
|
504
|
-
|
|
505
|
-
self?.captureSession?.startRunning()
|
|
506
|
-
}
|
|
559
|
+
captureSession.startRunning()
|
|
507
560
|
}
|
|
508
561
|
}
|
|
509
562
|
|
|
@@ -513,7 +566,7 @@ extension CameraController {
|
|
|
513
566
|
// Remove only video inputs
|
|
514
567
|
captureSession.inputs.forEach { input in
|
|
515
568
|
if (input as? AVCaptureDeviceInput)?.device.hasMediaType(.video) ?? false {
|
|
516
|
-
|
|
569
|
+
captureSession.removeInput(input)
|
|
517
570
|
}
|
|
518
571
|
}
|
|
519
572
|
|
|
@@ -532,7 +585,7 @@ extension CameraController {
|
|
|
532
585
|
rearCamera.unlockForConfiguration()
|
|
533
586
|
|
|
534
587
|
if let newInput = try? AVCaptureDeviceInput(device: rearCamera),
|
|
535
|
-
|
|
588
|
+
captureSession.canAddInput(newInput) {
|
|
536
589
|
captureSession.addInput(newInput)
|
|
537
590
|
rearCameraInput = newInput
|
|
538
591
|
self.currentCameraPosition = .rear
|
|
@@ -552,7 +605,7 @@ extension CameraController {
|
|
|
552
605
|
frontCamera.unlockForConfiguration()
|
|
553
606
|
|
|
554
607
|
if let newInput = try? AVCaptureDeviceInput(device: frontCamera),
|
|
555
|
-
|
|
608
|
+
captureSession.canAddInput(newInput) {
|
|
556
609
|
captureSession.addInput(newInput)
|
|
557
610
|
frontCameraInput = newInput
|
|
558
611
|
self.currentCameraPosition = .front
|
|
@@ -567,27 +620,59 @@ extension CameraController {
|
|
|
567
620
|
}
|
|
568
621
|
|
|
569
622
|
// Update video orientation
|
|
623
|
+
self.updateVideoOrientation()
|
|
624
|
+
|
|
625
|
+
// Set default 1.0 zoom level after camera switch to prevent iOS 18+ zoom jumps
|
|
570
626
|
DispatchQueue.main.async { [weak self] in
|
|
571
|
-
self?.
|
|
627
|
+
self?.setDefaultZoomAfterFlip()
|
|
572
628
|
}
|
|
573
629
|
}
|
|
574
630
|
|
|
575
|
-
func captureImage(width: Int?, height: Int?, quality: Float, gpsLocation: CLLocation?, completion: @escaping (UIImage?, Error?) -> Void) {
|
|
631
|
+
func captureImage(width: Int?, height: Int?, aspectRatio: String?, quality: Float, gpsLocation: CLLocation?, completion: @escaping (UIImage?, Data?, [AnyHashable: Any]?, Error?) -> Void) {
|
|
632
|
+
print("[CameraPreview] captureImage called - width: \(width ?? -1), height: \(height ?? -1), aspectRatio: \(aspectRatio ?? "nil")")
|
|
633
|
+
|
|
576
634
|
guard let photoOutput = self.photoOutput else {
|
|
577
|
-
completion(nil, NSError(domain: "Camera", code: 0, userInfo: [NSLocalizedDescriptionKey: "Photo output is not available"]))
|
|
635
|
+
completion(nil, nil, nil, NSError(domain: "Camera", code: 0, userInfo: [NSLocalizedDescriptionKey: "Photo output is not available"]))
|
|
578
636
|
return
|
|
579
637
|
}
|
|
580
638
|
|
|
581
639
|
let settings = AVCapturePhotoSettings()
|
|
640
|
+
// Request highest quality photo capture
|
|
641
|
+
if #available(iOS 13.0, *) {
|
|
642
|
+
settings.isHighResolutionPhotoEnabled = true
|
|
643
|
+
}
|
|
644
|
+
if #available(iOS 15.0, *) {
|
|
645
|
+
settings.photoQualityPrioritization = .balanced
|
|
646
|
+
}
|
|
582
647
|
|
|
583
|
-
|
|
648
|
+
// Apply the current flash mode to the photo settings
|
|
649
|
+
// Check if the current device supports flash
|
|
650
|
+
var currentCamera: AVCaptureDevice?
|
|
651
|
+
switch currentCameraPosition {
|
|
652
|
+
case .front:
|
|
653
|
+
currentCamera = self.frontCamera
|
|
654
|
+
case .rear:
|
|
655
|
+
currentCamera = self.rearCamera
|
|
656
|
+
default:
|
|
657
|
+
break
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// Only apply flash if the device has flash and the flash mode is supported
|
|
661
|
+
if let device = currentCamera, device.hasFlash {
|
|
662
|
+
let supportedFlashModes = photoOutput.supportedFlashModes
|
|
663
|
+
if supportedFlashModes.contains(self.flashMode) {
|
|
664
|
+
settings.flashMode = self.flashMode
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
self.photoCaptureCompletionBlock = { (image, photoData, metadata, error) in
|
|
584
669
|
if let error = error {
|
|
585
|
-
completion(nil, error)
|
|
670
|
+
completion(nil, nil, nil, error)
|
|
586
671
|
return
|
|
587
672
|
}
|
|
588
673
|
|
|
589
674
|
guard let image = image else {
|
|
590
|
-
completion(nil, NSError(domain: "Camera", code: 0, userInfo: [NSLocalizedDescriptionKey: "Failed to capture image"]))
|
|
675
|
+
completion(nil, nil, nil, NSError(domain: "Camera", code: 0, userInfo: [NSLocalizedDescriptionKey: "Failed to capture image"]))
|
|
591
676
|
return
|
|
592
677
|
}
|
|
593
678
|
|
|
@@ -595,12 +680,53 @@ extension CameraController {
|
|
|
595
680
|
self.addGPSMetadata(to: image, location: location)
|
|
596
681
|
}
|
|
597
682
|
|
|
683
|
+
var finalImage = image
|
|
684
|
+
|
|
685
|
+
// Determine what to do based on parameters
|
|
598
686
|
if let width = width, let height = height {
|
|
599
|
-
|
|
600
|
-
|
|
687
|
+
// Specific dimensions requested - resize to exact size
|
|
688
|
+
finalImage = self.resizeImage(image: image, to: CGSize(width: width, height: height))!
|
|
689
|
+
print("[CameraPreview] Resized to exact dimensions: \(finalImage.size.width)x\(finalImage.size.height)")
|
|
690
|
+
} else if let aspectRatio = aspectRatio {
|
|
691
|
+
// Aspect ratio specified - crop to that ratio
|
|
692
|
+
let components = aspectRatio.split(separator: ":").compactMap { Double($0) }
|
|
693
|
+
if components.count == 2 {
|
|
694
|
+
// For capture in portrait orientation, swap the aspect ratio (16:9 becomes 9:16)
|
|
695
|
+
let isPortrait = image.size.height > image.size.width
|
|
696
|
+
let targetAspectRatio = isPortrait ? components[1] / components[0] : components[0] / components[1]
|
|
697
|
+
let imageSize = image.size
|
|
698
|
+
let originalAspectRatio = imageSize.width / imageSize.height
|
|
699
|
+
|
|
700
|
+
// Only crop if the aspect ratios don't match
|
|
701
|
+
if abs(originalAspectRatio - targetAspectRatio) > 0.01 {
|
|
702
|
+
var targetSize = imageSize
|
|
703
|
+
|
|
704
|
+
if originalAspectRatio > targetAspectRatio {
|
|
705
|
+
// Original is wider than target - fit by height
|
|
706
|
+
targetSize.width = imageSize.height * CGFloat(targetAspectRatio)
|
|
707
|
+
} else {
|
|
708
|
+
// Original is taller than target - fit by width
|
|
709
|
+
targetSize.height = imageSize.width / CGFloat(targetAspectRatio)
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// Center crop the image
|
|
713
|
+
if let croppedImage = self.cropImageToAspectRatio(image: image, targetSize: targetSize) {
|
|
714
|
+
finalImage = croppedImage
|
|
715
|
+
print("[CameraPreview] Applied aspect ratio crop: \(finalImage.size.width)x\(finalImage.size.height)")
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
}
|
|
601
719
|
} else {
|
|
602
|
-
|
|
720
|
+
// No parameters specified - crop to match what's visible in the preview
|
|
721
|
+
// This ensures we capture exactly what the user sees
|
|
722
|
+
if let previewLayer = self.previewLayer,
|
|
723
|
+
let previewCroppedImage = self.cropImageToMatchPreview(image: image, previewLayer: previewLayer) {
|
|
724
|
+
finalImage = previewCroppedImage
|
|
725
|
+
print("[CameraPreview] Cropped to match preview: \(finalImage.size.width)x\(finalImage.size.height)")
|
|
726
|
+
}
|
|
603
727
|
}
|
|
728
|
+
|
|
729
|
+
completion(finalImage, photoData, metadata, nil)
|
|
604
730
|
}
|
|
605
731
|
|
|
606
732
|
photoOutput.capturePhoto(with: settings, delegate: self)
|
|
@@ -637,12 +763,73 @@ extension CameraController {
|
|
|
637
763
|
|
|
638
764
|
func resizeImage(image: UIImage, to size: CGSize) -> UIImage? {
|
|
639
765
|
let renderer = UIGraphicsImageRenderer(size: size)
|
|
640
|
-
let resizedImage = renderer.image { (
|
|
766
|
+
let resizedImage = renderer.image { (_) in
|
|
641
767
|
image.draw(in: CGRect(origin: .zero, size: size))
|
|
642
768
|
}
|
|
643
769
|
return resizedImage
|
|
644
770
|
}
|
|
645
771
|
|
|
772
|
+
func cropImageToAspectRatio(image: UIImage, targetSize: CGSize) -> UIImage? {
|
|
773
|
+
let imageSize = image.size
|
|
774
|
+
|
|
775
|
+
// Calculate the crop rect - center crop
|
|
776
|
+
let xOffset = (imageSize.width - targetSize.width) / 2
|
|
777
|
+
let yOffset = (imageSize.height - targetSize.height) / 2
|
|
778
|
+
let cropRect = CGRect(x: xOffset, y: yOffset, width: targetSize.width, height: targetSize.height)
|
|
779
|
+
|
|
780
|
+
// Create the cropped image
|
|
781
|
+
guard let cgImage = image.cgImage,
|
|
782
|
+
let croppedCGImage = cgImage.cropping(to: cropRect) else {
|
|
783
|
+
return nil
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
return UIImage(cgImage: croppedCGImage, scale: image.scale, orientation: image.imageOrientation)
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
func cropImageToMatchPreview(image: UIImage, previewLayer: AVCaptureVideoPreviewLayer) -> UIImage? {
|
|
790
|
+
// When using resizeAspectFill, the preview layer shows a cropped portion of the video
|
|
791
|
+
// We need to calculate what portion of the captured image corresponds to what's visible
|
|
792
|
+
|
|
793
|
+
let previewBounds = previewLayer.bounds
|
|
794
|
+
let previewAspectRatio = previewBounds.width / previewBounds.height
|
|
795
|
+
|
|
796
|
+
// Get the dimensions of the captured image
|
|
797
|
+
let imageSize = image.size
|
|
798
|
+
let imageAspectRatio = imageSize.width / imageSize.height
|
|
799
|
+
|
|
800
|
+
print("[CameraPreview] cropImageToMatchPreview - Preview bounds: \(previewBounds.width)x\(previewBounds.height) (ratio: \(previewAspectRatio))")
|
|
801
|
+
print("[CameraPreview] cropImageToMatchPreview - Image size: \(imageSize.width)x\(imageSize.height) (ratio: \(imageAspectRatio))")
|
|
802
|
+
|
|
803
|
+
// Since we're using resizeAspectFill, we need to calculate what portion of the image
|
|
804
|
+
// is visible in the preview
|
|
805
|
+
var cropRect: CGRect
|
|
806
|
+
|
|
807
|
+
if imageAspectRatio > previewAspectRatio {
|
|
808
|
+
// Image is wider than preview - crop horizontally
|
|
809
|
+
let visibleWidth = imageSize.height * previewAspectRatio
|
|
810
|
+
let xOffset = (imageSize.width - visibleWidth) / 2
|
|
811
|
+
cropRect = CGRect(x: xOffset, y: 0, width: visibleWidth, height: imageSize.height)
|
|
812
|
+
|
|
813
|
+
} else {
|
|
814
|
+
// Image is taller than preview - crop vertically
|
|
815
|
+
let visibleHeight = imageSize.width / previewAspectRatio
|
|
816
|
+
let yOffset = (imageSize.height - visibleHeight) / 2
|
|
817
|
+
cropRect = CGRect(x: 0, y: yOffset, width: imageSize.width, height: visibleHeight)
|
|
818
|
+
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// Create the cropped image
|
|
822
|
+
guard let cgImage = image.cgImage,
|
|
823
|
+
let croppedCGImage = cgImage.cropping(to: cropRect) else {
|
|
824
|
+
|
|
825
|
+
return nil
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
let result = UIImage(cgImage: croppedCGImage, scale: image.scale, orientation: image.imageOrientation)
|
|
829
|
+
|
|
830
|
+
return result
|
|
831
|
+
}
|
|
832
|
+
|
|
646
833
|
func captureSample(completion: @escaping (UIImage?, Error?) -> Void) {
|
|
647
834
|
guard let captureSession = captureSession,
|
|
648
835
|
captureSession.isRunning else {
|
|
@@ -697,6 +884,7 @@ extension CameraController {
|
|
|
697
884
|
return supportedFlashModesAsStrings
|
|
698
885
|
|
|
699
886
|
}
|
|
887
|
+
|
|
700
888
|
func getHorizontalFov() throws -> Float {
|
|
701
889
|
var currentCamera: AVCaptureDevice?
|
|
702
890
|
switch currentCameraPosition {
|
|
@@ -723,6 +911,7 @@ extension CameraController {
|
|
|
723
911
|
|
|
724
912
|
return adjustedFov
|
|
725
913
|
}
|
|
914
|
+
|
|
726
915
|
func setFlashMode(flashMode: AVCaptureDevice.FlashMode) throws {
|
|
727
916
|
var currentCamera: AVCaptureDevice?
|
|
728
917
|
switch currentCameraPosition {
|
|
@@ -796,6 +985,7 @@ extension CameraController {
|
|
|
796
985
|
|
|
797
986
|
func getZoom() throws -> (min: Float, max: Float, current: Float) {
|
|
798
987
|
var currentCamera: AVCaptureDevice?
|
|
988
|
+
|
|
799
989
|
switch currentCameraPosition {
|
|
800
990
|
case .front:
|
|
801
991
|
currentCamera = self.frontCamera
|
|
@@ -817,7 +1007,7 @@ extension CameraController {
|
|
|
817
1007
|
)
|
|
818
1008
|
}
|
|
819
1009
|
|
|
820
|
-
func setZoom(level: CGFloat, ramp: Bool) throws {
|
|
1010
|
+
func setZoom(level: CGFloat, ramp: Bool, autoFocus: Bool = true) throws {
|
|
821
1011
|
var currentCamera: AVCaptureDevice?
|
|
822
1012
|
switch currentCameraPosition {
|
|
823
1013
|
case .front:
|
|
@@ -848,9 +1038,121 @@ extension CameraController {
|
|
|
848
1038
|
|
|
849
1039
|
// Update our internal zoom factor tracking
|
|
850
1040
|
self.zoomFactor = zoomLevel
|
|
1041
|
+
|
|
1042
|
+
// Trigger autofocus after zoom if requested
|
|
1043
|
+
if autoFocus {
|
|
1044
|
+
self.triggerAutoFocus()
|
|
1045
|
+
}
|
|
1046
|
+
} catch {
|
|
1047
|
+
throw CameraControllerError.invalidOperation
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
private func triggerAutoFocus() {
|
|
1052
|
+
var currentCamera: AVCaptureDevice?
|
|
1053
|
+
switch currentCameraPosition {
|
|
1054
|
+
case .front:
|
|
1055
|
+
currentCamera = self.frontCamera
|
|
1056
|
+
case .rear:
|
|
1057
|
+
currentCamera = self.rearCamera
|
|
1058
|
+
default: break
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
guard let device = currentCamera else {
|
|
1062
|
+
return
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
// Focus on the center of the preview (0.5, 0.5)
|
|
1066
|
+
let centerPoint = CGPoint(x: 0.5, y: 0.5)
|
|
1067
|
+
|
|
1068
|
+
do {
|
|
1069
|
+
try device.lockForConfiguration()
|
|
1070
|
+
|
|
1071
|
+
// Set focus mode to auto if supported
|
|
1072
|
+
if device.isFocusModeSupported(.autoFocus) {
|
|
1073
|
+
device.focusMode = .autoFocus
|
|
1074
|
+
if device.isFocusPointOfInterestSupported {
|
|
1075
|
+
device.focusPointOfInterest = centerPoint
|
|
1076
|
+
}
|
|
1077
|
+
} else if device.isFocusModeSupported(.continuousAutoFocus) {
|
|
1078
|
+
device.focusMode = .continuousAutoFocus
|
|
1079
|
+
if device.isFocusPointOfInterestSupported {
|
|
1080
|
+
device.focusPointOfInterest = centerPoint
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
// Also set exposure point if supported
|
|
1085
|
+
if device.isExposurePointOfInterestSupported && device.isExposureModeSupported(.autoExpose) {
|
|
1086
|
+
device.exposureMode = .autoExpose
|
|
1087
|
+
device.exposurePointOfInterest = centerPoint
|
|
1088
|
+
} else if device.isExposureModeSupported(.continuousAutoExposure) {
|
|
1089
|
+
device.exposureMode = .continuousAutoExposure
|
|
1090
|
+
if device.isExposurePointOfInterestSupported {
|
|
1091
|
+
device.exposurePointOfInterest = centerPoint
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
device.unlockForConfiguration()
|
|
851
1096
|
} catch {
|
|
1097
|
+
// Silently ignore errors during autofocus
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
func setFocus(at point: CGPoint, showIndicator: Bool = false, in view: UIView? = nil) throws {
|
|
1102
|
+
// Validate that coordinates are within bounds (0-1 range for device coordinates)
|
|
1103
|
+
if point.x < 0 || point.x > 1 || point.y < 0 || point.y > 1 {
|
|
1104
|
+
print("setFocus: Coordinates out of bounds - x: \(point.x), y: \(point.y)")
|
|
852
1105
|
throw CameraControllerError.invalidOperation
|
|
853
1106
|
}
|
|
1107
|
+
|
|
1108
|
+
var currentCamera: AVCaptureDevice?
|
|
1109
|
+
switch currentCameraPosition {
|
|
1110
|
+
case .front:
|
|
1111
|
+
currentCamera = self.frontCamera
|
|
1112
|
+
case .rear:
|
|
1113
|
+
currentCamera = self.rearCamera
|
|
1114
|
+
default: break
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
guard let device = currentCamera else {
|
|
1118
|
+
throw CameraControllerError.noCamerasAvailable
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
guard device.isFocusPointOfInterestSupported else {
|
|
1122
|
+
// Device doesn't support focus point of interest
|
|
1123
|
+
return
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
// Show focus indicator if requested and view is provided - only after validation
|
|
1127
|
+
if showIndicator, let view = view, let previewLayer = self.previewLayer {
|
|
1128
|
+
// Convert the device point to layer point for indicator display
|
|
1129
|
+
let layerPoint = previewLayer.layerPointConverted(fromCaptureDevicePoint: point)
|
|
1130
|
+
showFocusIndicator(at: layerPoint, in: view)
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
do {
|
|
1134
|
+
try device.lockForConfiguration()
|
|
1135
|
+
|
|
1136
|
+
// Set focus mode to auto if supported
|
|
1137
|
+
if device.isFocusModeSupported(.autoFocus) {
|
|
1138
|
+
device.focusMode = .autoFocus
|
|
1139
|
+
} else if device.isFocusModeSupported(.continuousAutoFocus) {
|
|
1140
|
+
device.focusMode = .continuousAutoFocus
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
// Set the focus point
|
|
1144
|
+
device.focusPointOfInterest = point
|
|
1145
|
+
|
|
1146
|
+
// Also set exposure point if supported
|
|
1147
|
+
if device.isExposurePointOfInterestSupported && device.isExposureModeSupported(.autoExpose) {
|
|
1148
|
+
device.exposureMode = .autoExpose
|
|
1149
|
+
device.exposurePointOfInterest = point
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
device.unlockForConfiguration()
|
|
1153
|
+
} catch {
|
|
1154
|
+
throw CameraControllerError.unknown
|
|
1155
|
+
}
|
|
854
1156
|
}
|
|
855
1157
|
|
|
856
1158
|
func getFlashMode() throws -> String {
|
|
@@ -963,9 +1265,7 @@ extension CameraController {
|
|
|
963
1265
|
captureSession.commitConfiguration()
|
|
964
1266
|
// Restart the session if it was running before
|
|
965
1267
|
if wasRunning {
|
|
966
|
-
|
|
967
|
-
self?.captureSession?.startRunning()
|
|
968
|
-
}
|
|
1268
|
+
captureSession.startRunning()
|
|
969
1269
|
}
|
|
970
1270
|
}
|
|
971
1271
|
|
|
@@ -1012,13 +1312,9 @@ extension CameraController {
|
|
|
1012
1312
|
}
|
|
1013
1313
|
|
|
1014
1314
|
// Update video orientation
|
|
1015
|
-
|
|
1016
|
-
self?.updateVideoOrientation()
|
|
1017
|
-
}
|
|
1315
|
+
self.updateVideoOrientation()
|
|
1018
1316
|
}
|
|
1019
1317
|
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
1318
|
func cleanup() {
|
|
1023
1319
|
if let captureSession = self.captureSession {
|
|
1024
1320
|
captureSession.stopRunning()
|
|
@@ -1029,6 +1325,9 @@ extension CameraController {
|
|
|
1029
1325
|
self.previewLayer?.removeFromSuperlayer()
|
|
1030
1326
|
self.previewLayer = nil
|
|
1031
1327
|
|
|
1328
|
+
self.focusIndicatorView?.removeFromSuperview()
|
|
1329
|
+
self.focusIndicatorView = nil
|
|
1330
|
+
|
|
1032
1331
|
self.frontCameraInput = nil
|
|
1033
1332
|
self.rearCameraInput = nil
|
|
1034
1333
|
self.audioInput = nil
|
|
@@ -1036,6 +1335,7 @@ extension CameraController {
|
|
|
1036
1335
|
self.frontCamera = nil
|
|
1037
1336
|
self.rearCamera = nil
|
|
1038
1337
|
self.audioDevice = nil
|
|
1338
|
+
self.allDiscoveredDevices = []
|
|
1039
1339
|
|
|
1040
1340
|
self.dataOutput = nil
|
|
1041
1341
|
self.photoOutput = nil
|
|
@@ -1043,6 +1343,13 @@ extension CameraController {
|
|
|
1043
1343
|
|
|
1044
1344
|
self.captureSession = nil
|
|
1045
1345
|
self.currentCameraPosition = nil
|
|
1346
|
+
|
|
1347
|
+
// Reset output preparation status
|
|
1348
|
+
self.outputsPrepared = false
|
|
1349
|
+
|
|
1350
|
+
// Reset first frame detection
|
|
1351
|
+
self.hasReceivedFirstFrame = false
|
|
1352
|
+
self.firstFrameReadyCallback = nil
|
|
1046
1353
|
}
|
|
1047
1354
|
|
|
1048
1355
|
func captureVideo() throws {
|
|
@@ -1119,6 +1426,11 @@ extension CameraController: UIGestureRecognizerDelegate {
|
|
|
1119
1426
|
let point = tap.location(in: tap.view)
|
|
1120
1427
|
let devicePoint = self.previewLayer?.captureDevicePointConverted(fromLayerPoint: point)
|
|
1121
1428
|
|
|
1429
|
+
// Show focus indicator at the tap point
|
|
1430
|
+
if let view = tap.view {
|
|
1431
|
+
showFocusIndicator(at: point, in: view)
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1122
1434
|
do {
|
|
1123
1435
|
try device.lockForConfiguration()
|
|
1124
1436
|
defer { device.unlockForConfiguration() }
|
|
@@ -1139,7 +1451,80 @@ extension CameraController: UIGestureRecognizerDelegate {
|
|
|
1139
1451
|
}
|
|
1140
1452
|
}
|
|
1141
1453
|
|
|
1142
|
-
|
|
1454
|
+
private func showFocusIndicator(at point: CGPoint, in view: UIView) {
|
|
1455
|
+
// Remove any existing focus indicator
|
|
1456
|
+
focusIndicatorView?.removeFromSuperview()
|
|
1457
|
+
|
|
1458
|
+
// Create a new focus indicator (iOS Camera style): square with mid-edge ticks
|
|
1459
|
+
let indicator = UIView(frame: CGRect(x: 0, y: 0, width: 80, height: 80))
|
|
1460
|
+
indicator.center = point
|
|
1461
|
+
indicator.layer.borderColor = UIColor.yellow.cgColor
|
|
1462
|
+
indicator.layer.borderWidth = 2.0
|
|
1463
|
+
indicator.layer.cornerRadius = 0
|
|
1464
|
+
indicator.backgroundColor = UIColor.clear
|
|
1465
|
+
indicator.alpha = 0
|
|
1466
|
+
indicator.transform = CGAffineTransform(scaleX: 1.5, y: 1.5)
|
|
1467
|
+
|
|
1468
|
+
// Add 4 tiny mid-edge ticks inside the square
|
|
1469
|
+
let stroke: CGFloat = 2.0
|
|
1470
|
+
let tickLen: CGFloat = 12.0
|
|
1471
|
+
let inset: CGFloat = stroke // ticks should touch the sides
|
|
1472
|
+
// Top tick (perpendicular): vertical inward from top edge
|
|
1473
|
+
let topTick = UIView(frame: CGRect(x: (indicator.bounds.width - stroke)/2,
|
|
1474
|
+
y: inset,
|
|
1475
|
+
width: stroke,
|
|
1476
|
+
height: tickLen))
|
|
1477
|
+
topTick.backgroundColor = .yellow
|
|
1478
|
+
indicator.addSubview(topTick)
|
|
1479
|
+
// Bottom tick (perpendicular): vertical inward from bottom edge
|
|
1480
|
+
let bottomTick = UIView(frame: CGRect(x: (indicator.bounds.width - stroke)/2,
|
|
1481
|
+
y: indicator.bounds.height - inset - tickLen,
|
|
1482
|
+
width: stroke,
|
|
1483
|
+
height: tickLen))
|
|
1484
|
+
bottomTick.backgroundColor = .yellow
|
|
1485
|
+
indicator.addSubview(bottomTick)
|
|
1486
|
+
// Left tick (perpendicular): horizontal inward from left edge
|
|
1487
|
+
let leftTick = UIView(frame: CGRect(x: inset,
|
|
1488
|
+
y: (indicator.bounds.height - stroke)/2,
|
|
1489
|
+
width: tickLen,
|
|
1490
|
+
height: stroke))
|
|
1491
|
+
leftTick.backgroundColor = .yellow
|
|
1492
|
+
indicator.addSubview(leftTick)
|
|
1493
|
+
// Right tick (perpendicular): horizontal inward from right edge
|
|
1494
|
+
let rightTick = UIView(frame: CGRect(x: indicator.bounds.width - inset - tickLen,
|
|
1495
|
+
y: (indicator.bounds.height - stroke)/2,
|
|
1496
|
+
width: tickLen,
|
|
1497
|
+
height: stroke))
|
|
1498
|
+
rightTick.backgroundColor = .yellow
|
|
1499
|
+
indicator.addSubview(rightTick)
|
|
1500
|
+
|
|
1501
|
+
view.addSubview(indicator)
|
|
1502
|
+
focusIndicatorView = indicator
|
|
1503
|
+
|
|
1504
|
+
// Animate the focus indicator
|
|
1505
|
+
UIView.animate(withDuration: 0.15, animations: {
|
|
1506
|
+
indicator.alpha = 1.0
|
|
1507
|
+
indicator.transform = CGAffineTransform.identity
|
|
1508
|
+
}) { _ in
|
|
1509
|
+
// Keep the indicator visible briefly
|
|
1510
|
+
UIView.animate(withDuration: 0.2, delay: 0.5, options: [], animations: {
|
|
1511
|
+
indicator.alpha = 0.3
|
|
1512
|
+
}) { _ in
|
|
1513
|
+
// Fade out and remove
|
|
1514
|
+
UIView.animate(withDuration: 0.3, delay: 0.2, options: [], animations: {
|
|
1515
|
+
indicator.alpha = 0
|
|
1516
|
+
indicator.transform = CGAffineTransform(scaleX: 0.8, y: 0.8)
|
|
1517
|
+
}) { _ in
|
|
1518
|
+
indicator.removeFromSuperview()
|
|
1519
|
+
if self.focusIndicatorView == indicator {
|
|
1520
|
+
self.focusIndicatorView = nil
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
@objc
|
|
1143
1528
|
private func handlePinch(_ pinch: UIPinchGestureRecognizer) {
|
|
1144
1529
|
guard let device = self.currentCameraPosition == .rear ? rearCamera : frontCamera else { return }
|
|
1145
1530
|
|
|
@@ -1151,7 +1536,7 @@ extension CameraController: UIGestureRecognizerDelegate {
|
|
|
1151
1536
|
// Store the initial zoom factor when pinch begins
|
|
1152
1537
|
zoomFactor = device.videoZoomFactor
|
|
1153
1538
|
|
|
1154
|
-
|
|
1539
|
+
case .changed:
|
|
1155
1540
|
// Throttle zoom updates to prevent excessive CPU usage
|
|
1156
1541
|
let currentTime = CACurrentMediaTime()
|
|
1157
1542
|
guard currentTime - lastZoomUpdateTime >= zoomUpdateThrottle else { return }
|
|
@@ -1181,21 +1566,41 @@ extension CameraController: UIGestureRecognizerDelegate {
|
|
|
1181
1566
|
}
|
|
1182
1567
|
|
|
1183
1568
|
extension CameraController: AVCapturePhotoCaptureDelegate {
|
|
1184
|
-
public func photoOutput(_
|
|
1185
|
-
resolvedSettings: AVCaptureResolvedPhotoSettings, bracketSettings: AVCaptureBracketedStillImageSettings?, error: Swift.Error?) {
|
|
1569
|
+
public func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) {
|
|
1186
1570
|
if let error = error {
|
|
1187
|
-
self.photoCaptureCompletionBlock?(nil, error)
|
|
1188
|
-
|
|
1189
|
-
let image = UIImage(data: data) {
|
|
1190
|
-
self.photoCaptureCompletionBlock?(image.fixedOrientation(), nil)
|
|
1191
|
-
} else {
|
|
1192
|
-
self.photoCaptureCompletionBlock?(nil, CameraControllerError.unknown)
|
|
1571
|
+
self.photoCaptureCompletionBlock?(nil, nil, nil, error)
|
|
1572
|
+
return
|
|
1193
1573
|
}
|
|
1574
|
+
|
|
1575
|
+
// Get the photo data using the modern API
|
|
1576
|
+
guard let imageData = photo.fileDataRepresentation() else {
|
|
1577
|
+
self.photoCaptureCompletionBlock?(nil, nil, nil, CameraControllerError.unknown)
|
|
1578
|
+
return
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
guard let image = UIImage(data: imageData) else {
|
|
1582
|
+
self.photoCaptureCompletionBlock?(nil, nil, nil, CameraControllerError.unknown)
|
|
1583
|
+
return
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
// Pass through original file data and metadata so callers can preserve EXIF
|
|
1587
|
+
self.photoCaptureCompletionBlock?(image.fixedOrientation(), imageData, photo.metadata, nil)
|
|
1194
1588
|
}
|
|
1195
1589
|
}
|
|
1196
1590
|
|
|
1197
1591
|
extension CameraController: AVCaptureVideoDataOutputSampleBufferDelegate {
|
|
1198
1592
|
func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
|
|
1593
|
+
// Check if we're waiting for the first frame
|
|
1594
|
+
if !hasReceivedFirstFrame, let firstFrameCallback = firstFrameReadyCallback {
|
|
1595
|
+
hasReceivedFirstFrame = true
|
|
1596
|
+
firstFrameCallback()
|
|
1597
|
+
firstFrameReadyCallback = nil
|
|
1598
|
+
// If no capture is in progress, we can return early
|
|
1599
|
+
if sampleBufferCaptureCompletionBlock == nil {
|
|
1600
|
+
return
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1199
1604
|
guard let completion = sampleBufferCaptureCompletionBlock else { return }
|
|
1200
1605
|
|
|
1201
1606
|
guard let imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else {
|
|
@@ -1245,6 +1650,7 @@ enum CameraControllerError: Swift.Error {
|
|
|
1245
1650
|
case cannotFindDocumentsDirectory
|
|
1246
1651
|
case fileVideoOutputNotFound
|
|
1247
1652
|
case unknown
|
|
1653
|
+
case invalidZoomLevel(min: CGFloat, max: CGFloat, requested: CGFloat)
|
|
1248
1654
|
}
|
|
1249
1655
|
|
|
1250
1656
|
public enum CameraPosition {
|
|
@@ -1271,6 +1677,8 @@ extension CameraControllerError: LocalizedError {
|
|
|
1271
1677
|
return NSLocalizedString("Cannot find documents directory", comment: "This should never happen")
|
|
1272
1678
|
case .fileVideoOutputNotFound:
|
|
1273
1679
|
return NSLocalizedString("Video recording is not available. Make sure the camera is properly initialized.", comment: "Video recording not available")
|
|
1680
|
+
case .invalidZoomLevel(let min, let max, let requested):
|
|
1681
|
+
return NSLocalizedString("Invalid zoom level. Must be between \(min) and \(max). Requested: \(requested)", comment: "Invalid Zoom Level")
|
|
1274
1682
|
}
|
|
1275
1683
|
}
|
|
1276
1684
|
}
|
|
@@ -1312,6 +1720,8 @@ extension UIImage {
|
|
|
1312
1720
|
print("right")
|
|
1313
1721
|
case .up, .upMirrored:
|
|
1314
1722
|
break
|
|
1723
|
+
@unknown default:
|
|
1724
|
+
break
|
|
1315
1725
|
}
|
|
1316
1726
|
|
|
1317
1727
|
// Flip image one more time if needed to, this is to prevent flipped image
|
|
@@ -1324,15 +1734,21 @@ extension UIImage {
|
|
|
1324
1734
|
transform.scaledBy(x: -1, y: 1)
|
|
1325
1735
|
case .up, .down, .left, .right:
|
|
1326
1736
|
break
|
|
1737
|
+
@unknown default:
|
|
1738
|
+
break
|
|
1327
1739
|
}
|
|
1328
1740
|
|
|
1329
1741
|
ctx.concatenate(transform)
|
|
1330
1742
|
|
|
1331
1743
|
switch imageOrientation {
|
|
1332
1744
|
case .left, .leftMirrored, .right, .rightMirrored:
|
|
1333
|
-
|
|
1745
|
+
if let cgImage = self.cgImage {
|
|
1746
|
+
ctx.draw(cgImage, in: CGRect(x: 0, y: 0, width: size.height, height: size.width))
|
|
1747
|
+
}
|
|
1334
1748
|
default:
|
|
1335
|
-
|
|
1749
|
+
if let cgImage = self.cgImage {
|
|
1750
|
+
ctx.draw(cgImage, in: CGRect(x: 0, y: 0, width: size.width, height: size.height))
|
|
1751
|
+
}
|
|
1336
1752
|
}
|
|
1337
1753
|
guard let newCGImage = ctx.makeImage() else { return nil }
|
|
1338
1754
|
return UIImage.init(cgImage: newCGImage, scale: 1, orientation: .up)
|