capacitor-camera-view 2.4.0 → 3.0.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/CapacitorCameraView.podspec +1 -1
- package/Package.swift +1 -1
- package/README.md +341 -49
- package/android/build.gradle +0 -1
- package/android/src/main/AndroidManifest.xml +0 -1
- package/android/src/main/java/com/michaelwolz/capacitorcameraview/CameraError.kt +42 -0
- package/android/src/main/java/com/michaelwolz/capacitorcameraview/CameraView.kt +945 -207
- package/android/src/main/java/com/michaelwolz/capacitorcameraview/CameraViewPlugin.kt +87 -43
- package/android/src/main/java/com/michaelwolz/capacitorcameraview/model/BarcodeDetectionResult.kt +28 -2
- package/android/src/main/java/com/michaelwolz/capacitorcameraview/model/CameraDevice.kt +6 -1
- package/android/src/main/java/com/michaelwolz/capacitorcameraview/model/CameraSessionConfiguration.kt +15 -1
- package/android/src/main/java/com/michaelwolz/capacitorcameraview/model/TorchModeState.kt +15 -0
- package/android/src/main/java/com/michaelwolz/capacitorcameraview/model/WebBoundingRect.kt +1 -1
- package/android/src/main/java/com/michaelwolz/capacitorcameraview/utils.kt +73 -19
- package/dist/docs.json +475 -30
- package/dist/esm/definitions.d.ts +478 -26
- package/dist/esm/definitions.js.map +1 -1
- package/dist/esm/utils.d.ts +59 -15
- package/dist/esm/utils.js +79 -38
- package/dist/esm/utils.js.map +1 -1
- package/dist/esm/web.d.ts +191 -13
- package/dist/esm/web.js +624 -141
- package/dist/esm/web.js.map +1 -1
- package/dist/plugin.cjs.js +711 -179
- package/dist/plugin.cjs.js.map +1 -1
- package/dist/plugin.js +711 -179
- package/dist/plugin.js.map +1 -1
- package/ios/Sources/CameraViewPlugin/CameraError.swift +147 -2
- package/ios/Sources/CameraViewPlugin/CameraEvents.swift +41 -19
- package/ios/Sources/CameraViewPlugin/CameraSessionConfiguration.swift +29 -1
- package/ios/Sources/CameraViewPlugin/CameraViewManager+BarcodeScan.swift +78 -23
- package/ios/Sources/CameraViewPlugin/CameraViewManager+DeferredStart.swift +37 -0
- package/ios/Sources/CameraViewPlugin/CameraViewManager+Focus.swift +131 -0
- package/ios/Sources/CameraViewPlugin/CameraViewManager+Lifecycle.swift +156 -0
- package/ios/Sources/CameraViewPlugin/CameraViewManager+PhotoCapture.swift +73 -41
- package/ios/Sources/CameraViewPlugin/CameraViewManager+ResolutionSelection.swift +57 -0
- package/ios/Sources/CameraViewPlugin/CameraViewManager+Rotation.swift +195 -0
- package/ios/Sources/CameraViewPlugin/CameraViewManager+VideoDataOutput.swift +24 -12
- package/ios/Sources/CameraViewPlugin/CameraViewManager+VideoRecording.swift +22 -73
- package/ios/Sources/CameraViewPlugin/CameraViewManager+Zoom.swift +113 -0
- package/ios/Sources/CameraViewPlugin/CameraViewManager.swift +450 -403
- package/ios/Sources/CameraViewPlugin/CameraViewPlugin.swift +98 -65
- package/ios/Sources/CameraViewPlugin/Utils.swift +61 -7
- package/package.json +25 -9
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
import AVFoundation
|
|
2
2
|
import Foundation
|
|
3
|
-
import UIKit
|
|
4
3
|
|
|
5
4
|
extension CameraViewManager: AVCapturePhotoCaptureDelegate {
|
|
6
5
|
/// Set up output for the capture session in case it's not configured yet
|
|
7
6
|
/// Make sure to call `captureSession.beginConfiguration` before calling this
|
|
8
7
|
///
|
|
8
|
+
/// - Parameter prioritizeQuality: When `true`, the iOS 17+ responsive-capture
|
|
9
|
+
/// optimizations are skipped so captures always prioritize quality.
|
|
9
10
|
/// - Throws: An error if the output cannot be set.
|
|
10
|
-
internal func setupPhotoOutput() throws {
|
|
11
|
+
internal func setupPhotoOutput(prioritizeQuality: Bool = false) throws {
|
|
11
12
|
if (captureSession.outputs.contains { $0 is AVCapturePhotoOutput }) {
|
|
12
|
-
//
|
|
13
|
-
//
|
|
13
|
+
// The output already exists (session restart) - still reconfigure
|
|
14
|
+
// the responsiveness pipeline, since prioritizeQuality may have
|
|
15
|
+
// changed between sessions.
|
|
16
|
+
configureResponsiveCapture(prioritizeQuality: prioritizeQuality)
|
|
14
17
|
return
|
|
15
18
|
}
|
|
16
19
|
|
|
@@ -22,12 +25,67 @@ extension CameraViewManager: AVCapturePhotoCaptureDelegate {
|
|
|
22
25
|
}
|
|
23
26
|
|
|
24
27
|
captureSession.addOutput(avPhotoOutput)
|
|
28
|
+
deferStart(of: avPhotoOutput)
|
|
29
|
+
|
|
30
|
+
configureResponsiveCapture(prioritizeQuality: prioritizeQuality)
|
|
25
31
|
}
|
|
26
32
|
|
|
27
|
-
///
|
|
33
|
+
/// Applies the iOS 17+ capture-responsiveness state on the photo output where
|
|
34
|
+
/// supported, reducing shot-to-shot latency.
|
|
28
35
|
///
|
|
29
|
-
///
|
|
30
|
-
///
|
|
36
|
+
/// Ordering matters: responsive capture requires zero-shutter-lag, and fast
|
|
37
|
+
/// capture prioritization requires responsive capture, so capabilities are
|
|
38
|
+
/// enabled prerequisite-first and disabled dependent-first.
|
|
39
|
+
///
|
|
40
|
+
/// - Parameter prioritizeQuality: When `true`, the optimizations are turned
|
|
41
|
+
/// off (not merely skipped) so a session restart that flips the option
|
|
42
|
+
/// always prioritizes quality.
|
|
43
|
+
private func configureResponsiveCapture(prioritizeQuality: Bool) {
|
|
44
|
+
if #available(iOS 17.0, *) {
|
|
45
|
+
let enable = !prioritizeQuality
|
|
46
|
+
|
|
47
|
+
if enable {
|
|
48
|
+
if avPhotoOutput.isZeroShutterLagSupported {
|
|
49
|
+
avPhotoOutput.isZeroShutterLagEnabled = true
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if avPhotoOutput.isResponsiveCaptureSupported {
|
|
53
|
+
avPhotoOutput.isResponsiveCaptureEnabled = true
|
|
54
|
+
|
|
55
|
+
// Fast capture prioritization requires responsive capture
|
|
56
|
+
// to be enabled first.
|
|
57
|
+
if avPhotoOutput.isFastCapturePrioritizationSupported {
|
|
58
|
+
avPhotoOutput.isFastCapturePrioritizationEnabled = true
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
} else {
|
|
62
|
+
// Disable dependents before their prerequisites.
|
|
63
|
+
if avPhotoOutput.isFastCapturePrioritizationSupported {
|
|
64
|
+
avPhotoOutput.isFastCapturePrioritizationEnabled = false
|
|
65
|
+
}
|
|
66
|
+
if avPhotoOutput.isResponsiveCaptureSupported {
|
|
67
|
+
avPhotoOutput.isResponsiveCaptureEnabled = false
|
|
68
|
+
}
|
|
69
|
+
if avPhotoOutput.isZeroShutterLagSupported {
|
|
70
|
+
avPhotoOutput.isZeroShutterLagEnabled = false
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/// Atomically consumes the photo completion handler so a photo-output
|
|
77
|
+
/// delegate callback can never race handler assignment on the capture thread.
|
|
78
|
+
internal func consumePhotoDataHandler() -> ((Data?, Error?) -> Void)? {
|
|
79
|
+
captureHandlerLock.lock()
|
|
80
|
+
defer { captureHandlerLock.unlock() }
|
|
81
|
+
|
|
82
|
+
let dataHandler = photoDataCaptureHandler
|
|
83
|
+
photoDataCaptureHandler = nil
|
|
84
|
+
return dataHandler
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/// Delegate method called when a photo has been captured via `AVCapturePhotoCaptureDelegate`.
|
|
88
|
+
/// Returns the camera's native JPEG data directly, avoiding a double JPEG encode.
|
|
31
89
|
///
|
|
32
90
|
/// - Parameters:
|
|
33
91
|
/// - output: The photo output that captured the photo.
|
|
@@ -38,45 +96,19 @@ extension CameraViewManager: AVCapturePhotoCaptureDelegate {
|
|
|
38
96
|
didFinishProcessingPhoto photo: AVCapturePhoto,
|
|
39
97
|
error: Error?
|
|
40
98
|
) {
|
|
41
|
-
|
|
42
|
-
if let dataHandler = photoDataCaptureHandler {
|
|
43
|
-
photoDataCaptureHandler = nil
|
|
99
|
+
guard let dataHandler = consumePhotoDataHandler() else { return }
|
|
44
100
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
return
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
guard let data = photo.fileDataRepresentation() else {
|
|
51
|
-
dataHandler(nil, CameraError.photoOutputError)
|
|
52
|
-
return
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
dataHandler(data, nil)
|
|
101
|
+
if let error = error {
|
|
102
|
+
dataHandler(nil, error)
|
|
56
103
|
return
|
|
57
104
|
}
|
|
58
105
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
if let error = error {
|
|
64
|
-
imageHandler(nil, error)
|
|
65
|
-
return
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
guard let data = photo.fileDataRepresentation() else {
|
|
69
|
-
imageHandler(nil, CameraError.photoOutputError)
|
|
70
|
-
return
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
guard let image = UIImage(data: data) else {
|
|
74
|
-
imageHandler(nil, CameraError.photoOutputError)
|
|
75
|
-
return
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
imageHandler(image, nil)
|
|
106
|
+
guard let data = photo.fileDataRepresentation() else {
|
|
107
|
+
dataHandler(nil, CameraError.photoOutputError)
|
|
108
|
+
return
|
|
79
109
|
}
|
|
110
|
+
|
|
111
|
+
dataHandler(data, nil)
|
|
80
112
|
}
|
|
81
113
|
|
|
82
114
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import AVFoundation
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
/// Resolution / aspect-ratio selection for preview and photo capture.
|
|
5
|
+
///
|
|
6
|
+
/// The session preset drives the active format and therefore the aspect ratio
|
|
7
|
+
/// of both the preview stream and captured photos, keeping what the user sees
|
|
8
|
+
/// consistent with what gets captured.
|
|
9
|
+
extension CameraViewManager {
|
|
10
|
+
|
|
11
|
+
/// Applies the session preset matching the configured aspect ratio.
|
|
12
|
+
///
|
|
13
|
+
/// "16:9" prefers the HD video presets (highest first); "4:3" and `nil` use
|
|
14
|
+
/// the `.photo` preset with the sensor's native 4:3 photo format.
|
|
15
|
+
///
|
|
16
|
+
/// Must run inside a configuration transaction on the session queue.
|
|
17
|
+
///
|
|
18
|
+
/// - Parameter aspectRatio: The configured aspect ratio ("4:3", "16:9" or `nil`).
|
|
19
|
+
internal func applySessionPreset(forAspectRatio aspectRatio: String?) {
|
|
20
|
+
let preferredPresets: [AVCaptureSession.Preset] =
|
|
21
|
+
aspectRatio == "16:9"
|
|
22
|
+
? [.hd4K3840x2160, .hd1920x1080, .hd1280x720, .photo]
|
|
23
|
+
: [.photo]
|
|
24
|
+
|
|
25
|
+
for preset in preferredPresets where captureSession.canSetSessionPreset(preset) {
|
|
26
|
+
captureSession.sessionPreset = preset
|
|
27
|
+
return
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/// Re-applies the configured capture-resolution hint to the photo output.
|
|
32
|
+
///
|
|
33
|
+
/// Picks the largest supported photo dimensions whose longer edge does not
|
|
34
|
+
/// exceed the hint, falling back to the smallest supported dimensions.
|
|
35
|
+
///
|
|
36
|
+
/// `maxPhotoDimensions` is only valid against the *current* active format's
|
|
37
|
+
/// `supportedMaxPhotoDimensions` and is reset by AVFoundation whenever the
|
|
38
|
+
/// active format changes, so it must be re-applied on the session queue
|
|
39
|
+
/// after every committed transaction that changes the format.
|
|
40
|
+
internal func applyConfiguredMaxPhotoDimensions() {
|
|
41
|
+
guard let maxDimension = configuredCaptureMaxDimension,
|
|
42
|
+
let device = currentCameraDevice else { return }
|
|
43
|
+
|
|
44
|
+
let supported = device.activeFormat.supportedMaxPhotoDimensions
|
|
45
|
+
|
|
46
|
+
let byArea: (CMVideoDimensions, CMVideoDimensions) -> Bool = {
|
|
47
|
+
Int64($0.width) * Int64($0.height) < Int64($1.width) * Int64($1.height)
|
|
48
|
+
}
|
|
49
|
+
let fitting = supported.filter { Int(max($0.width, $0.height)) <= maxDimension }
|
|
50
|
+
|
|
51
|
+
guard let chosen = fitting.max(by: byArea) ?? supported.min(by: byArea) else {
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
avPhotoOutput.maxPhotoDimensions = chosen
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import AVFoundation
|
|
2
|
+
import Foundation
|
|
3
|
+
import UIKit
|
|
4
|
+
|
|
5
|
+
/// Rotation handling for the camera preview and capture outputs.
|
|
6
|
+
///
|
|
7
|
+
/// On iOS 17+ this adopts `AVCaptureDevice.RotationCoordinator`, which reports
|
|
8
|
+
/// horizon-level rotation angles for the preview and for capture and updates
|
|
9
|
+
/// them continuously without relying on `UIDevice` orientation notifications.
|
|
10
|
+
/// On iOS 16 it falls back to the legacy `videoOrientation` API driven by device
|
|
11
|
+
/// orientation changes (see `setupOrientationObserver`).
|
|
12
|
+
extension CameraViewManager {
|
|
13
|
+
|
|
14
|
+
// MARK: - iOS 17+ Rotation Coordinator
|
|
15
|
+
|
|
16
|
+
/// Typed accessor over the `Any?`-backed coordinator storage.
|
|
17
|
+
///
|
|
18
|
+
/// Takes `rotationCoordinatorLock`: the storage is written on the main queue
|
|
19
|
+
/// but read from the Capacitor call thread and the session queue, and an
|
|
20
|
+
/// unsynchronized ARC reassignment racing a read is undefined behavior.
|
|
21
|
+
@available(iOS 17.0, *)
|
|
22
|
+
internal var rotationCoordinator: AVCaptureDevice.RotationCoordinator? {
|
|
23
|
+
get {
|
|
24
|
+
rotationCoordinatorLock.lock()
|
|
25
|
+
defer { rotationCoordinatorLock.unlock() }
|
|
26
|
+
return rotationCoordinatorStorage as? AVCaptureDevice.RotationCoordinator
|
|
27
|
+
}
|
|
28
|
+
set {
|
|
29
|
+
rotationCoordinatorLock.lock()
|
|
30
|
+
defer { rotationCoordinatorLock.unlock() }
|
|
31
|
+
rotationCoordinatorStorage = newValue
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/// (Re)configures rotation handling for the current camera device. Safe to
|
|
36
|
+
/// call whenever the active device changes (flip, triple-camera upgrade) or
|
|
37
|
+
/// once the preview becomes available.
|
|
38
|
+
internal func configureRotationHandling() {
|
|
39
|
+
if #available(iOS 17.0, *) {
|
|
40
|
+
setupRotationCoordinator()
|
|
41
|
+
} else {
|
|
42
|
+
DispatchQueue.main.async { [weak self] in
|
|
43
|
+
self?.updateLegacyPreviewOrientation()
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/// Creates a rotation coordinator for the active device and observes its
|
|
49
|
+
/// preview angle to keep the preview level with the horizon. Recreated on
|
|
50
|
+
/// every device change so the angles track the physical camera in use.
|
|
51
|
+
@available(iOS 17.0, *)
|
|
52
|
+
private func setupRotationCoordinator() {
|
|
53
|
+
DispatchQueue.main.async { [weak self] in
|
|
54
|
+
guard let self = self,
|
|
55
|
+
let device = self.currentCameraDevice,
|
|
56
|
+
self.videoPreviewLayer.session != nil else { return }
|
|
57
|
+
|
|
58
|
+
// Invalidate the previous observation before replacing the coordinator.
|
|
59
|
+
self.rotationObservation?.invalidate()
|
|
60
|
+
|
|
61
|
+
let coordinator = AVCaptureDevice.RotationCoordinator(
|
|
62
|
+
device: device,
|
|
63
|
+
previewLayer: self.videoPreviewLayer
|
|
64
|
+
)
|
|
65
|
+
self.rotationCoordinator = coordinator
|
|
66
|
+
|
|
67
|
+
// `.initial` applies the current angle immediately; subsequent
|
|
68
|
+
// changes keep the preview rotating continuously.
|
|
69
|
+
self.rotationObservation = coordinator.observe(
|
|
70
|
+
\.videoRotationAngleForHorizonLevelPreview,
|
|
71
|
+
options: [.initial, .new]
|
|
72
|
+
) { [weak self] coordinator, _ in
|
|
73
|
+
self?.applyPreviewRotationAngle(
|
|
74
|
+
coordinator.videoRotationAngleForHorizonLevelPreview
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/// Applies a preview rotation angle to the preview layer's connection.
|
|
81
|
+
@available(iOS 17.0, *)
|
|
82
|
+
private func applyPreviewRotationAngle(_ angle: CGFloat) {
|
|
83
|
+
DispatchQueue.main.async { [weak self] in
|
|
84
|
+
guard let self = self,
|
|
85
|
+
let connection = self.videoPreviewLayer.connection,
|
|
86
|
+
connection.isVideoRotationAngleSupported(angle) else { return }
|
|
87
|
+
|
|
88
|
+
connection.videoRotationAngle = angle
|
|
89
|
+
|
|
90
|
+
// Keep the preview layer filling the (possibly rotated) bounds.
|
|
91
|
+
if let view = self.webView {
|
|
92
|
+
self.videoPreviewLayer.frame = view.bounds
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// MARK: - Capture Orientation
|
|
98
|
+
|
|
99
|
+
/// Applies the correct rotation to a capture output connection (photo,
|
|
100
|
+
/// sample, or movie) so captured media matches the device orientation.
|
|
101
|
+
internal func applyCaptureOrientation(to connection: AVCaptureConnection) {
|
|
102
|
+
if #available(iOS 17.0, *) {
|
|
103
|
+
// A nil coordinator only happens for a capture racing session
|
|
104
|
+
// teardown; leaving the connection at its default angle is harmless.
|
|
105
|
+
guard let coordinator = rotationCoordinator else { return }
|
|
106
|
+
let angle = coordinator.videoRotationAngleForHorizonLevelCapture
|
|
107
|
+
if connection.isVideoRotationAngleSupported(angle) {
|
|
108
|
+
connection.videoRotationAngle = angle
|
|
109
|
+
}
|
|
110
|
+
} else {
|
|
111
|
+
applyLegacyCaptureOrientation(to: connection)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// MARK: - iOS 16 Legacy Path
|
|
116
|
+
|
|
117
|
+
/// Sets up device orientation handling (called from `init`).
|
|
118
|
+
///
|
|
119
|
+
/// No-op on iOS 17+, where the rotation coordinator takes over. On iOS 16
|
|
120
|
+
/// the plugin generates the device orientation notifications itself rather
|
|
121
|
+
/// than relying on the host app having started them.
|
|
122
|
+
internal func setupOrientationObserver() {
|
|
123
|
+
if #available(iOS 17.0, *) {
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
UIDevice.current.beginGeneratingDeviceOrientationNotifications()
|
|
128
|
+
orientationObserverToken = NotificationCenter.default.addObserver(
|
|
129
|
+
forName: UIDevice.orientationDidChangeNotification,
|
|
130
|
+
object: nil,
|
|
131
|
+
queue: .main
|
|
132
|
+
) { [weak self] _ in
|
|
133
|
+
self?.updateLegacyPreviewOrientation()
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/// Tears down the block-based orientation observer registered by
|
|
138
|
+
/// `setupOrientationObserver`. `NotificationCenter.removeObserver(self)`
|
|
139
|
+
/// does not remove block-based observers, so the token must be removed
|
|
140
|
+
/// explicitly or it leaks for the process lifetime.
|
|
141
|
+
internal func removeOrientationObserver() {
|
|
142
|
+
if let token = orientationObserverToken {
|
|
143
|
+
NotificationCenter.default.removeObserver(token)
|
|
144
|
+
orientationObserverToken = nil
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if #unavailable(iOS 17.0) {
|
|
148
|
+
UIDevice.current.endGeneratingDeviceOrientationNotifications()
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/// Legacy capture orientation: mirror the preview connection's
|
|
153
|
+
/// `videoOrientation` onto the capture connection.
|
|
154
|
+
@available(iOS, introduced: 16.0, deprecated: 17.0,
|
|
155
|
+
message: "Uses videoOrientation; replaced by RotationCoordinator on iOS 17+")
|
|
156
|
+
private func applyLegacyCaptureOrientation(to connection: AVCaptureConnection) {
|
|
157
|
+
guard let previewConnection = videoPreviewLayer.connection,
|
|
158
|
+
connection.isVideoOrientationSupported else { return }
|
|
159
|
+
connection.videoOrientation = previewConnection.videoOrientation
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/// Legacy preview orientation: derive `videoOrientation` from the current
|
|
163
|
+
/// interface orientation. Driven by `UIDevice` orientation notifications.
|
|
164
|
+
@available(iOS, introduced: 16.0, deprecated: 17.0,
|
|
165
|
+
message: "Uses videoOrientation; replaced by RotationCoordinator on iOS 17+")
|
|
166
|
+
internal func updateLegacyPreviewOrientation() {
|
|
167
|
+
guard let connection = videoPreviewLayer.connection,
|
|
168
|
+
connection.isVideoOrientationSupported else { return }
|
|
169
|
+
|
|
170
|
+
let interfaceOrientation = UIApplication.shared.connectedScenes
|
|
171
|
+
.compactMap { $0 as? UIWindowScene }
|
|
172
|
+
.first?.interfaceOrientation ?? .portrait
|
|
173
|
+
|
|
174
|
+
let videoOrientation: AVCaptureVideoOrientation
|
|
175
|
+
switch interfaceOrientation {
|
|
176
|
+
case .portrait:
|
|
177
|
+
videoOrientation = .portrait
|
|
178
|
+
case .landscapeLeft:
|
|
179
|
+
videoOrientation = .landscapeLeft
|
|
180
|
+
case .landscapeRight:
|
|
181
|
+
videoOrientation = .landscapeRight
|
|
182
|
+
case .portraitUpsideDown:
|
|
183
|
+
videoOrientation = .portraitUpsideDown
|
|
184
|
+
default:
|
|
185
|
+
videoOrientation = .portrait
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
connection.videoOrientation = videoOrientation
|
|
189
|
+
|
|
190
|
+
// Update the frame of the preview layer to match the new bounds.
|
|
191
|
+
if let view = webView {
|
|
192
|
+
videoPreviewLayer.frame = view.bounds
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -31,6 +31,21 @@ extension CameraViewManager: AVCaptureVideoDataOutputSampleBufferDelegate {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
captureSession.addOutput(avVideoDataOutput)
|
|
34
|
+
deferStart(of: avVideoDataOutput)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/// Atomically consumes the snapshot completion handler and cancels its
|
|
38
|
+
/// timeout. Returns the handler to the caller that wins the race (frame
|
|
39
|
+
/// delivery or timeout) and `nil` to any later caller.
|
|
40
|
+
internal func consumeSnapshotHandler() -> ((UIImage?, Error?) -> Void)? {
|
|
41
|
+
captureHandlerLock.lock()
|
|
42
|
+
defer { captureHandlerLock.unlock() }
|
|
43
|
+
|
|
44
|
+
guard let handler = snapshotCompletionHandler else { return nil }
|
|
45
|
+
snapshotCompletionHandler = nil
|
|
46
|
+
snapshotTimeoutWorkItem?.cancel()
|
|
47
|
+
snapshotTimeoutWorkItem = nil
|
|
48
|
+
return handler
|
|
34
49
|
}
|
|
35
50
|
|
|
36
51
|
/// Capture a snapshot from the camera feed using the shared Metal-backed CIContext.
|
|
@@ -39,12 +54,12 @@ extension CameraViewManager: AVCaptureVideoDataOutputSampleBufferDelegate {
|
|
|
39
54
|
_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer,
|
|
40
55
|
from connection: AVCaptureConnection
|
|
41
56
|
) {
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
snapshotCompletionHandler = nil
|
|
57
|
+
// Atomically claim the snapshot; returns nil if it was already consumed
|
|
58
|
+
// (e.g. by the timeout) or if no capture is in flight. This also cancels
|
|
59
|
+
// the timeout so the promise settles exactly once.
|
|
60
|
+
guard let completionHandler = consumeSnapshotHandler() else { return }
|
|
47
61
|
|
|
62
|
+
// Stop delivery to ensure we only capture one frame.
|
|
48
63
|
avVideoDataOutput.setSampleBufferDelegate(nil, queue: nil)
|
|
49
64
|
|
|
50
65
|
guard let imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else {
|
|
@@ -68,12 +83,9 @@ extension CameraViewManager: AVCaptureVideoDataOutputSampleBufferDelegate {
|
|
|
68
83
|
_ output: AVCaptureOutput, didDrop sampleBuffer: CMSampleBuffer,
|
|
69
84
|
from connection: AVCaptureConnection
|
|
70
85
|
) {
|
|
71
|
-
//
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
completionHandler(nil, CameraError.frameCaptureError)
|
|
77
|
-
}
|
|
86
|
+
// With `alwaysDiscardsLateVideoFrames` enabled a transient dropped frame
|
|
87
|
+
// is normal and must not fail the capture. Keep the delegate installed
|
|
88
|
+
// and wait for the next delivered frame; the timeout guards against the
|
|
89
|
+
// case where no frame ever arrives.
|
|
78
90
|
}
|
|
79
91
|
}
|
|
@@ -29,14 +29,7 @@ extension CameraViewManager: AVCaptureFileOutputRecordingDelegate {
|
|
|
29
29
|
guard let self = self else { return }
|
|
30
30
|
|
|
31
31
|
guard self.captureSession.isRunning else {
|
|
32
|
-
|
|
33
|
-
// when reconfiguring audio after a microphone permission grant). Wait for
|
|
34
|
-
// it to resume and retry rather than failing immediately.
|
|
35
|
-
self.waitForSessionThenStartRecording(
|
|
36
|
-
enableAudio: enableAudio,
|
|
37
|
-
videoQuality: videoQuality,
|
|
38
|
-
completion: completion
|
|
39
|
-
)
|
|
32
|
+
DispatchQueue.main.async { completion(CameraError.sessionNotRunning) }
|
|
40
33
|
return
|
|
41
34
|
}
|
|
42
35
|
|
|
@@ -64,6 +57,11 @@ extension CameraViewManager: AVCaptureFileOutputRecordingDelegate {
|
|
|
64
57
|
return
|
|
65
58
|
}
|
|
66
59
|
self.captureSession.addOutput(self.avMovieOutput)
|
|
60
|
+
|
|
61
|
+
// Never deferred: this output is added because a recording is
|
|
62
|
+
// starting right now, and file outputs default to deferred for
|
|
63
|
+
// host apps linked against the iOS 26 SDK.
|
|
64
|
+
self.deferStart(of: self.avMovieOutput, false)
|
|
67
65
|
}
|
|
68
66
|
|
|
69
67
|
// Add audio input if requested
|
|
@@ -83,11 +81,8 @@ extension CameraViewManager: AVCaptureFileOutputRecordingDelegate {
|
|
|
83
81
|
self.captureSession.commitConfiguration()
|
|
84
82
|
|
|
85
83
|
// Set orientation on the movie output connection
|
|
86
|
-
if let connection = self.avMovieOutput.connection(with: .video)
|
|
87
|
-
|
|
88
|
-
if connection.isVideoOrientationSupported {
|
|
89
|
-
connection.videoOrientation = previewConnection.videoOrientation
|
|
90
|
-
}
|
|
84
|
+
if let connection = self.avMovieOutput.connection(with: .video) {
|
|
85
|
+
self.applyCaptureOrientation(to: connection)
|
|
91
86
|
if connection.isVideoMirroringSupported {
|
|
92
87
|
connection.isVideoMirrored = self.currentCameraDevice?.position == .front
|
|
93
88
|
}
|
|
@@ -156,6 +151,11 @@ extension CameraViewManager: AVCaptureFileOutputRecordingDelegate {
|
|
|
156
151
|
self.restoreSessionPreset()
|
|
157
152
|
self.recordingWithAudio = false
|
|
158
153
|
self.captureSession.commitConfiguration()
|
|
154
|
+
|
|
155
|
+
// Restoring the pre-recording preset may change the active format,
|
|
156
|
+
// which resets the photo output's maxPhotoDimensions; re-apply the
|
|
157
|
+
// session's capture-resolution hint.
|
|
158
|
+
self.applyConfiguredMaxPhotoDimensions()
|
|
159
159
|
}
|
|
160
160
|
|
|
161
161
|
if let error = error {
|
|
@@ -172,65 +172,6 @@ extension CameraViewManager: AVCaptureFileOutputRecordingDelegate {
|
|
|
172
172
|
|
|
173
173
|
// MARK: - Private Helpers
|
|
174
174
|
|
|
175
|
-
/// Waits for the capture session to start running, then retries `startRecording`.
|
|
176
|
-
///
|
|
177
|
-
/// Called when `startRecording` finds the session temporarily stopped (e.g. after
|
|
178
|
-
/// iOS reconfigures audio following a first-time microphone permission grant).
|
|
179
|
-
/// Both the notification path and the timeout path serialize through `sessionQueue`,
|
|
180
|
-
/// so `handled` is accessed on a single serial queue and needs no additional lock.
|
|
181
|
-
private func waitForSessionThenStartRecording(
|
|
182
|
-
enableAudio: Bool,
|
|
183
|
-
videoQuality: VideoRecordingQuality,
|
|
184
|
-
completion: @escaping (Error?) -> Void
|
|
185
|
-
) {
|
|
186
|
-
let sessionQueue = self.sessionQueue
|
|
187
|
-
// Keep the token so we can remove the observer on success and timeout paths.
|
|
188
|
-
var observerToken: NSObjectProtocol?
|
|
189
|
-
var handled = false
|
|
190
|
-
|
|
191
|
-
observerToken = NotificationCenter.default.addObserver(
|
|
192
|
-
forName: .AVCaptureSessionDidStartRunning,
|
|
193
|
-
object: captureSession,
|
|
194
|
-
queue: nil
|
|
195
|
-
) { [weak self] _ in
|
|
196
|
-
sessionQueue.async {
|
|
197
|
-
guard !handled else { return }
|
|
198
|
-
handled = true
|
|
199
|
-
if let token = observerToken {
|
|
200
|
-
NotificationCenter.default.removeObserver(token)
|
|
201
|
-
observerToken = nil
|
|
202
|
-
}
|
|
203
|
-
guard let self = self else { return }
|
|
204
|
-
self.startRecording(
|
|
205
|
-
enableAudio: enableAudio,
|
|
206
|
-
videoQuality: videoQuality,
|
|
207
|
-
completion: completion
|
|
208
|
-
)
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
// Timeout: if the session hasn't restarted within 2 seconds, give up.
|
|
213
|
-
sessionQueue.asyncAfter(deadline: .now() + 2.0) { [weak self] in
|
|
214
|
-
guard !handled else { return }
|
|
215
|
-
handled = true
|
|
216
|
-
if let token = observerToken {
|
|
217
|
-
NotificationCenter.default.removeObserver(token)
|
|
218
|
-
observerToken = nil
|
|
219
|
-
}
|
|
220
|
-
guard let self = self else { return }
|
|
221
|
-
// One final check in case the session started just as we timed out.
|
|
222
|
-
if self.captureSession.isRunning {
|
|
223
|
-
self.startRecording(
|
|
224
|
-
enableAudio: enableAudio,
|
|
225
|
-
videoQuality: videoQuality,
|
|
226
|
-
completion: completion
|
|
227
|
-
)
|
|
228
|
-
} else {
|
|
229
|
-
DispatchQueue.main.async { completion(CameraError.sessionNotRunning) }
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
|
|
234
175
|
/// Resolves the appropriate AVCaptureSession.Preset for the given VideoRecordingQuality,
|
|
235
176
|
private func resolveRecordingPreset(for videoQuality: VideoRecordingQuality) -> AVCaptureSession.Preset {
|
|
236
177
|
let preferredPresets: [AVCaptureSession.Preset]
|
|
@@ -276,7 +217,15 @@ extension CameraViewManager: AVCaptureFileOutputRecordingDelegate {
|
|
|
276
217
|
(input as? AVCaptureDeviceInput)?.device.hasMediaType(.audio) == true
|
|
277
218
|
}
|
|
278
219
|
|
|
279
|
-
guard !hasAudioInput else {
|
|
220
|
+
guard !hasAudioInput else {
|
|
221
|
+
// An audio input is already attached (e.g. a leftover from a
|
|
222
|
+
// previous recording). Recording will still include audio, so
|
|
223
|
+
// `recordingWithAudio` must reflect that or the completion
|
|
224
|
+
// delegate won't remove the input afterwards, leaving it
|
|
225
|
+
// stray-attached to the session.
|
|
226
|
+
recordingWithAudio = true
|
|
227
|
+
return
|
|
228
|
+
}
|
|
280
229
|
|
|
281
230
|
guard let microphone = AVCaptureDevice.default(for: .audio) else {
|
|
282
231
|
throw CameraError.audioDeviceUnavailable
|