@elizaos/capacitor-camera 1.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/ElizaosCapacitorCamera.podspec +18 -0
- package/android/build.gradle +61 -0
- package/android/src/main/AndroidManifest.xml +9 -0
- package/android/src/main/java/ai/eliza/plugins/camera/CameraPlugin.kt +1002 -0
- package/dist/esm/definitions.d.ts +191 -0
- package/dist/esm/definitions.d.ts.map +1 -0
- package/dist/esm/definitions.js +1 -0
- package/dist/esm/index.d.ts +4 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +6 -0
- package/dist/esm/web.d.ts +58 -0
- package/dist/esm/web.d.ts.map +1 -0
- package/dist/esm/web.js +541 -0
- package/dist/plugin.cjs.js +557 -0
- package/dist/plugin.cjs.js.map +1 -0
- package/dist/plugin.js +560 -0
- package/dist/plugin.js.map +1 -0
- package/electrobun/src/index.ts +13 -0
- package/electrobun/tsconfig.json +18 -0
- package/ios/Sources/CameraPlugin/CameraPlugin.swift +1225 -0
- package/package.json +81 -0
|
@@ -0,0 +1,1225 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import Capacitor
|
|
3
|
+
import AVFoundation
|
|
4
|
+
import UIKit
|
|
5
|
+
import Photos
|
|
6
|
+
import ImageIO
|
|
7
|
+
|
|
8
|
+
// MARK: - ElizaCameraPlugin
|
|
9
|
+
|
|
10
|
+
@objc(ElizaCameraPlugin)
|
|
11
|
+
public class ElizaCameraPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
12
|
+
public let identifier = "ElizaCameraPlugin"
|
|
13
|
+
public let jsName = "ElizaCamera"
|
|
14
|
+
public let pluginMethods: [CAPPluginMethod] = [
|
|
15
|
+
CAPPluginMethod(name: "getDevices", returnType: CAPPluginReturnPromise),
|
|
16
|
+
CAPPluginMethod(name: "startPreview", returnType: CAPPluginReturnPromise),
|
|
17
|
+
CAPPluginMethod(name: "stopPreview", returnType: CAPPluginReturnPromise),
|
|
18
|
+
CAPPluginMethod(name: "switchCamera", returnType: CAPPluginReturnPromise),
|
|
19
|
+
CAPPluginMethod(name: "capturePhoto", returnType: CAPPluginReturnPromise),
|
|
20
|
+
CAPPluginMethod(name: "startRecording", returnType: CAPPluginReturnPromise),
|
|
21
|
+
CAPPluginMethod(name: "stopRecording", returnType: CAPPluginReturnPromise),
|
|
22
|
+
CAPPluginMethod(name: "getRecordingState", returnType: CAPPluginReturnPromise),
|
|
23
|
+
CAPPluginMethod(name: "getSettings", returnType: CAPPluginReturnPromise),
|
|
24
|
+
CAPPluginMethod(name: "setSettings", returnType: CAPPluginReturnPromise),
|
|
25
|
+
CAPPluginMethod(name: "setZoom", returnType: CAPPluginReturnPromise),
|
|
26
|
+
CAPPluginMethod(name: "setFocusPoint", returnType: CAPPluginReturnPromise),
|
|
27
|
+
CAPPluginMethod(name: "setExposurePoint", returnType: CAPPluginReturnPromise),
|
|
28
|
+
CAPPluginMethod(name: "checkPermissions", returnType: CAPPluginReturnPromise),
|
|
29
|
+
CAPPluginMethod(name: "requestPermissions", returnType: CAPPluginReturnPromise),
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
// MARK: - Properties
|
|
33
|
+
|
|
34
|
+
private var captureSession: AVCaptureSession?
|
|
35
|
+
private var previewLayer: AVCaptureVideoPreviewLayer?
|
|
36
|
+
private var videoInput: AVCaptureDeviceInput?
|
|
37
|
+
private var photoOutput: AVCapturePhotoOutput?
|
|
38
|
+
private var movieOutput: AVCaptureMovieFileOutput?
|
|
39
|
+
private var videoDataOutput: AVCaptureVideoDataOutput?
|
|
40
|
+
private var currentDevice: AVCaptureDevice?
|
|
41
|
+
private var previewView: UIView?
|
|
42
|
+
private var isRecording = false
|
|
43
|
+
private var recordingStartTime: Date?
|
|
44
|
+
private var pendingPhotoCall: CAPPluginCall?
|
|
45
|
+
private var pendingVideoCall: CAPPluginCall?
|
|
46
|
+
private var currentPhotoOptions: [String: Any]?
|
|
47
|
+
private var currentVideoOptions: [String: Any]?
|
|
48
|
+
private var recordingTimer: Timer?
|
|
49
|
+
|
|
50
|
+
/// Timestamp of last emitted frame event; used to throttle to ~2/s.
|
|
51
|
+
private var lastFrameEmitTime: CFAbsoluteTime = 0
|
|
52
|
+
|
|
53
|
+
/// Serial queue for video data output sample buffer callbacks (frame events).
|
|
54
|
+
private let videoDataQueue = DispatchQueue(label: "eliza.camera.videodata", qos: .userInitiated)
|
|
55
|
+
|
|
56
|
+
/// All device types to discover. Ported from classic CameraController to include
|
|
57
|
+
/// dual, triple, TrueDepth, and LiDAR cameras.
|
|
58
|
+
private static let discoveryDeviceTypes: [AVCaptureDevice.DeviceType] = {
|
|
59
|
+
var types: [AVCaptureDevice.DeviceType] = [
|
|
60
|
+
.builtInWideAngleCamera,
|
|
61
|
+
.builtInUltraWideCamera,
|
|
62
|
+
.builtInTelephotoCamera,
|
|
63
|
+
.builtInDualCamera,
|
|
64
|
+
.builtInDualWideCamera,
|
|
65
|
+
.builtInTripleCamera,
|
|
66
|
+
.builtInTrueDepthCamera
|
|
67
|
+
]
|
|
68
|
+
if #available(iOS 15.4, *) {
|
|
69
|
+
types.append(.builtInLiDARDepthCamera)
|
|
70
|
+
}
|
|
71
|
+
return types
|
|
72
|
+
}()
|
|
73
|
+
|
|
74
|
+
private var currentSettings: [String: Any] = [
|
|
75
|
+
"flash": "off",
|
|
76
|
+
"zoom": 1.0,
|
|
77
|
+
"focusMode": "continuous",
|
|
78
|
+
"exposureMode": "continuous",
|
|
79
|
+
"exposureCompensation": 0.0,
|
|
80
|
+
"whiteBalance": "auto"
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
// MARK: - Helpers (ported from classic CameraController)
|
|
84
|
+
|
|
85
|
+
/// Pick a camera by deviceId or direction, with fallback to any available camera.
|
|
86
|
+
/// Ported from classic CameraController.pickCamera.
|
|
87
|
+
private func pickCamera(direction: String, deviceId: String?) -> AVCaptureDevice? {
|
|
88
|
+
if let deviceId = deviceId, !deviceId.isEmpty {
|
|
89
|
+
let all = AVCaptureDevice.DiscoverySession(
|
|
90
|
+
deviceTypes: Self.discoveryDeviceTypes,
|
|
91
|
+
mediaType: .video,
|
|
92
|
+
position: .unspecified
|
|
93
|
+
).devices
|
|
94
|
+
if let match = all.first(where: { $0.uniqueID == deviceId }) {
|
|
95
|
+
return match
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
let position: AVCaptureDevice.Position = direction == "front" ? .front : .back
|
|
99
|
+
if let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: position) {
|
|
100
|
+
return device
|
|
101
|
+
}
|
|
102
|
+
// Fallback to any default camera (e.g. simulator or unusual device configs).
|
|
103
|
+
return AVCaptureDevice.default(for: .video)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/// Clamp quality from 0-100 integer range to 0.05-1.0 float.
|
|
107
|
+
/// Ported from classic JPEGTranscoder.clampQuality.
|
|
108
|
+
private static func clampQuality(_ quality: Float) -> Float {
|
|
109
|
+
let q = quality / 100.0
|
|
110
|
+
return min(1.0, max(0.05, q))
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/// Emit an error event to JS listeners.
|
|
114
|
+
private func emitError(code: String, message: String) {
|
|
115
|
+
notifyListeners("error", data: [
|
|
116
|
+
"code": code,
|
|
117
|
+
"message": message,
|
|
118
|
+
])
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// MARK: - getDevices
|
|
122
|
+
|
|
123
|
+
@objc func getDevices(_ call: CAPPluginCall) {
|
|
124
|
+
var devices: [[String: Any]] = []
|
|
125
|
+
|
|
126
|
+
let discoverySession = AVCaptureDevice.DiscoverySession(
|
|
127
|
+
deviceTypes: Self.discoveryDeviceTypes,
|
|
128
|
+
mediaType: .video,
|
|
129
|
+
position: .unspecified
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
for device in discoverySession.devices {
|
|
133
|
+
var direction: String
|
|
134
|
+
switch device.position {
|
|
135
|
+
case .front: direction = "front"
|
|
136
|
+
case .back: direction = "back"
|
|
137
|
+
default: direction = "external"
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Deduplicated resolutions, sorted largest-first, capped at 10.
|
|
141
|
+
var resolutions: [[String: Int]] = []
|
|
142
|
+
for format in device.formats {
|
|
143
|
+
let dims = CMVideoFormatDescriptionGetDimensions(format.formatDescription)
|
|
144
|
+
let res = ["width": Int(dims.width), "height": Int(dims.height)]
|
|
145
|
+
if !resolutions.contains(where: { $0["width"] == res["width"] && $0["height"] == res["height"] }) {
|
|
146
|
+
resolutions.append(res)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
resolutions.sort { ($0["width"] ?? 0) * ($0["height"] ?? 0) > ($1["width"] ?? 0) * ($1["height"] ?? 0) }
|
|
150
|
+
if resolutions.count > 10 {
|
|
151
|
+
resolutions = Array(resolutions.prefix(10))
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Deduplicated max frame rates, sorted highest-first.
|
|
155
|
+
var frameRates: [Int] = []
|
|
156
|
+
for format in device.formats {
|
|
157
|
+
for range in format.videoSupportedFrameRateRanges {
|
|
158
|
+
let maxRate = Int(range.maxFrameRate)
|
|
159
|
+
if !frameRates.contains(maxRate) {
|
|
160
|
+
frameRates.append(maxRate)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
frameRates.sort(by: >)
|
|
165
|
+
|
|
166
|
+
devices.append([
|
|
167
|
+
"deviceId": device.uniqueID,
|
|
168
|
+
"label": device.localizedName,
|
|
169
|
+
"direction": direction,
|
|
170
|
+
"hasFlash": device.hasFlash,
|
|
171
|
+
"hasZoom": true,
|
|
172
|
+
"maxZoom": device.maxAvailableVideoZoomFactor,
|
|
173
|
+
"supportedResolutions": resolutions,
|
|
174
|
+
"supportedFrameRates": frameRates,
|
|
175
|
+
])
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
call.resolve(["devices": devices])
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// MARK: - startPreview
|
|
182
|
+
|
|
183
|
+
@objc func startPreview(_ call: CAPPluginCall) {
|
|
184
|
+
guard let webView = self.webView else {
|
|
185
|
+
call.reject("WebView not available")
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
let direction = call.getString("direction") ?? "back"
|
|
190
|
+
let deviceId = call.getString("deviceId")
|
|
191
|
+
let width = call.getInt("resolution.width") ?? 1920
|
|
192
|
+
let height = call.getInt("resolution.height") ?? 1080
|
|
193
|
+
let frameRate = call.getInt("frameRate") ?? 30
|
|
194
|
+
let mirror = call.getBool("mirror") ?? (direction == "front")
|
|
195
|
+
|
|
196
|
+
DispatchQueue.main.async { [weak self] in
|
|
197
|
+
guard let self = self else { return }
|
|
198
|
+
|
|
199
|
+
self.stopPreviewInternal()
|
|
200
|
+
|
|
201
|
+
let session = AVCaptureSession()
|
|
202
|
+
session.sessionPreset = .high
|
|
203
|
+
|
|
204
|
+
guard let captureDevice = self.pickCamera(direction: direction, deviceId: deviceId) else {
|
|
205
|
+
call.reject("No camera device available")
|
|
206
|
+
self.emitError(code: "CAMERA_UNAVAILABLE", message: "No camera device available")
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
do {
|
|
211
|
+
let input = try AVCaptureDeviceInput(device: captureDevice)
|
|
212
|
+
if session.canAddInput(input) {
|
|
213
|
+
session.addInput(input)
|
|
214
|
+
self.videoInput = input
|
|
215
|
+
self.currentDevice = captureDevice
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
let photoOutput = AVCapturePhotoOutput()
|
|
219
|
+
if session.canAddOutput(photoOutput) {
|
|
220
|
+
session.addOutput(photoOutput)
|
|
221
|
+
photoOutput.maxPhotoQualityPrioritization = .quality
|
|
222
|
+
self.photoOutput = photoOutput
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
let movieOutput = AVCaptureMovieFileOutput()
|
|
226
|
+
if session.canAddOutput(movieOutput) {
|
|
227
|
+
session.addOutput(movieOutput)
|
|
228
|
+
self.movieOutput = movieOutput
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Video data output for lightweight frame events.
|
|
232
|
+
let videoDataOutput = AVCaptureVideoDataOutput()
|
|
233
|
+
videoDataOutput.alwaysDiscardsLateVideoFrames = true
|
|
234
|
+
videoDataOutput.setSampleBufferDelegate(self, queue: self.videoDataQueue)
|
|
235
|
+
if session.canAddOutput(videoDataOutput) {
|
|
236
|
+
session.addOutput(videoDataOutput)
|
|
237
|
+
self.videoDataOutput = videoDataOutput
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
try captureDevice.lockForConfiguration()
|
|
241
|
+
let targetFrameRate = CMTime(value: 1, timescale: CMTimeScale(frameRate))
|
|
242
|
+
captureDevice.activeVideoMinFrameDuration = targetFrameRate
|
|
243
|
+
captureDevice.activeVideoMaxFrameDuration = targetFrameRate
|
|
244
|
+
captureDevice.unlockForConfiguration()
|
|
245
|
+
} catch {
|
|
246
|
+
call.reject("Failed to configure camera: \(error.localizedDescription)")
|
|
247
|
+
self.emitError(code: "CAMERA_CONFIG_FAILED", message: error.localizedDescription)
|
|
248
|
+
return
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
self.captureSession = session
|
|
252
|
+
|
|
253
|
+
let previewView = UIView(frame: webView.bounds)
|
|
254
|
+
previewView.backgroundColor = .black
|
|
255
|
+
previewView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
256
|
+
|
|
257
|
+
let previewLayer = AVCaptureVideoPreviewLayer(session: session)
|
|
258
|
+
previewLayer.frame = previewView.bounds
|
|
259
|
+
previewLayer.videoGravity = .resizeAspectFill
|
|
260
|
+
|
|
261
|
+
if mirror {
|
|
262
|
+
previewLayer.setAffineTransform(CGAffineTransform(scaleX: -1, y: 1))
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
previewView.layer.addSublayer(previewLayer)
|
|
266
|
+
|
|
267
|
+
webView.superview?.insertSubview(previewView, belowSubview: webView)
|
|
268
|
+
webView.isOpaque = false
|
|
269
|
+
webView.backgroundColor = .clear
|
|
270
|
+
webView.scrollView.backgroundColor = .clear
|
|
271
|
+
|
|
272
|
+
self.previewView = previewView
|
|
273
|
+
self.previewLayer = previewLayer
|
|
274
|
+
|
|
275
|
+
DispatchQueue.global(qos: .userInitiated).async {
|
|
276
|
+
session.startRunning()
|
|
277
|
+
|
|
278
|
+
// Short warm-up delay to reduce blank first frames.
|
|
279
|
+
// Ported from classic CameraController.warmUpCaptureSession (150ms).
|
|
280
|
+
Thread.sleep(forTimeInterval: 0.15)
|
|
281
|
+
|
|
282
|
+
DispatchQueue.main.async {
|
|
283
|
+
let formatDesc = self.currentDevice?.activeFormat.formatDescription
|
|
284
|
+
let dimensions = formatDesc.map { CMVideoFormatDescriptionGetDimensions($0) }
|
|
285
|
+
?? CMVideoDimensions(width: Int32(width), height: Int32(height))
|
|
286
|
+
|
|
287
|
+
call.resolve([
|
|
288
|
+
"width": Int(dimensions.width),
|
|
289
|
+
"height": Int(dimensions.height),
|
|
290
|
+
"deviceId": captureDevice.uniqueID,
|
|
291
|
+
])
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// MARK: - stopPreview
|
|
298
|
+
|
|
299
|
+
@objc func stopPreview(_ call: CAPPluginCall) {
|
|
300
|
+
DispatchQueue.main.async { [weak self] in
|
|
301
|
+
self?.stopPreviewInternal()
|
|
302
|
+
call.resolve()
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
private func stopPreviewInternal() {
|
|
307
|
+
if isRecording {
|
|
308
|
+
movieOutput?.stopRecording()
|
|
309
|
+
isRecording = false
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
recordingTimer?.invalidate()
|
|
313
|
+
recordingTimer = nil
|
|
314
|
+
|
|
315
|
+
captureSession?.stopRunning()
|
|
316
|
+
captureSession = nil
|
|
317
|
+
|
|
318
|
+
previewLayer?.removeFromSuperlayer()
|
|
319
|
+
previewLayer = nil
|
|
320
|
+
|
|
321
|
+
previewView?.removeFromSuperview()
|
|
322
|
+
previewView = nil
|
|
323
|
+
|
|
324
|
+
videoInput = nil
|
|
325
|
+
photoOutput = nil
|
|
326
|
+
movieOutput = nil
|
|
327
|
+
videoDataOutput = nil
|
|
328
|
+
currentDevice = nil
|
|
329
|
+
|
|
330
|
+
if let webView = self.webView {
|
|
331
|
+
webView.isOpaque = true
|
|
332
|
+
webView.backgroundColor = .white
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// MARK: - switchCamera
|
|
337
|
+
|
|
338
|
+
@objc func switchCamera(_ call: CAPPluginCall) {
|
|
339
|
+
guard captureSession != nil else {
|
|
340
|
+
call.reject("Preview not started")
|
|
341
|
+
return
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
let direction = call.getString("direction") ?? "back"
|
|
345
|
+
let deviceId = call.getString("deviceId")
|
|
346
|
+
|
|
347
|
+
DispatchQueue.main.async { [weak self] in
|
|
348
|
+
guard let self = self, let session = self.captureSession else { return }
|
|
349
|
+
|
|
350
|
+
session.beginConfiguration()
|
|
351
|
+
|
|
352
|
+
if let currentInput = self.videoInput {
|
|
353
|
+
session.removeInput(currentInput)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
guard let device = self.pickCamera(direction: direction, deviceId: deviceId) else {
|
|
357
|
+
session.commitConfiguration()
|
|
358
|
+
call.reject("Camera device not found")
|
|
359
|
+
return
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
do {
|
|
363
|
+
let input = try AVCaptureDeviceInput(device: device)
|
|
364
|
+
if session.canAddInput(input) {
|
|
365
|
+
session.addInput(input)
|
|
366
|
+
self.videoInput = input
|
|
367
|
+
self.currentDevice = device
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
let mirror = direction == "front"
|
|
371
|
+
if let previewLayer = self.previewLayer {
|
|
372
|
+
previewLayer.setAffineTransform(
|
|
373
|
+
mirror ? CGAffineTransform(scaleX: -1, y: 1) : .identity
|
|
374
|
+
)
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
session.commitConfiguration()
|
|
378
|
+
|
|
379
|
+
let dimensions = CMVideoFormatDescriptionGetDimensions(device.activeFormat.formatDescription)
|
|
380
|
+
call.resolve([
|
|
381
|
+
"width": Int(dimensions.width),
|
|
382
|
+
"height": Int(dimensions.height),
|
|
383
|
+
"deviceId": device.uniqueID,
|
|
384
|
+
])
|
|
385
|
+
} catch {
|
|
386
|
+
session.commitConfiguration()
|
|
387
|
+
call.reject("Failed to switch camera: \(error.localizedDescription)")
|
|
388
|
+
self.emitError(code: "CAMERA_SWITCH_FAILED", message: error.localizedDescription)
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// MARK: - capturePhoto
|
|
394
|
+
|
|
395
|
+
@objc func capturePhoto(_ call: CAPPluginCall) {
|
|
396
|
+
guard let photoOutput = self.photoOutput else {
|
|
397
|
+
call.reject("Camera not ready")
|
|
398
|
+
return
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
let quality = call.getFloat("quality") ?? 90
|
|
402
|
+
let format = call.getString("format") ?? "jpeg"
|
|
403
|
+
let saveToGallery = call.getBool("saveToGallery") ?? false
|
|
404
|
+
let includeExif = call.getBool("exifOrientation") ?? true
|
|
405
|
+
|
|
406
|
+
currentPhotoOptions = [
|
|
407
|
+
"quality": quality,
|
|
408
|
+
"format": format,
|
|
409
|
+
"saveToGallery": saveToGallery,
|
|
410
|
+
"width": call.getInt("width") as Any,
|
|
411
|
+
"height": call.getInt("height") as Any,
|
|
412
|
+
"includeExif": includeExif,
|
|
413
|
+
]
|
|
414
|
+
|
|
415
|
+
pendingPhotoCall = call
|
|
416
|
+
|
|
417
|
+
// Always capture as JPEG; format conversion happens in the delegate.
|
|
418
|
+
var settings: AVCapturePhotoSettings
|
|
419
|
+
if photoOutput.availablePhotoCodecTypes.contains(.jpeg) {
|
|
420
|
+
settings = AVCapturePhotoSettings(format: [AVVideoCodecKey: AVVideoCodecType.jpeg])
|
|
421
|
+
} else {
|
|
422
|
+
settings = AVCapturePhotoSettings()
|
|
423
|
+
}
|
|
424
|
+
settings.photoQualityPrioritization = .quality
|
|
425
|
+
|
|
426
|
+
if let device = currentDevice, device.hasFlash {
|
|
427
|
+
let flashMode = currentSettings["flash"] as? String ?? "off"
|
|
428
|
+
switch flashMode {
|
|
429
|
+
case "on": settings.flashMode = .on
|
|
430
|
+
case "auto": settings.flashMode = .auto
|
|
431
|
+
default: settings.flashMode = .off
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
photoOutput.capturePhoto(with: settings, delegate: self)
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// MARK: - startRecording
|
|
439
|
+
|
|
440
|
+
@objc func startRecording(_ call: CAPPluginCall) {
|
|
441
|
+
guard let movieOutput = self.movieOutput, !isRecording else {
|
|
442
|
+
call.reject("Cannot start recording")
|
|
443
|
+
return
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
let quality = call.getString("quality") ?? "high"
|
|
447
|
+
let maxDuration = call.getDouble("maxDuration")
|
|
448
|
+
let maxFileSize = call.getInt("maxFileSize")
|
|
449
|
+
let saveToGallery = call.getBool("saveToGallery") ?? false
|
|
450
|
+
let includeAudio = call.getBool("audio") ?? true
|
|
451
|
+
let frameRate = call.getInt("frameRate")
|
|
452
|
+
|
|
453
|
+
currentVideoOptions = [
|
|
454
|
+
"quality": quality,
|
|
455
|
+
"maxDuration": maxDuration as Any,
|
|
456
|
+
"maxFileSize": maxFileSize as Any,
|
|
457
|
+
"saveToGallery": saveToGallery,
|
|
458
|
+
"audio": includeAudio,
|
|
459
|
+
]
|
|
460
|
+
|
|
461
|
+
if includeAudio {
|
|
462
|
+
addAudioInput()
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if let maxDuration = maxDuration {
|
|
466
|
+
movieOutput.maxRecordedDuration = CMTime(seconds: maxDuration, preferredTimescale: 600)
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if let maxFileSize = maxFileSize {
|
|
470
|
+
movieOutput.maxRecordedFileSize = Int64(maxFileSize)
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Apply recording frame rate if specified.
|
|
474
|
+
if let frameRate = frameRate, let device = currentDevice {
|
|
475
|
+
do {
|
|
476
|
+
try device.lockForConfiguration()
|
|
477
|
+
let target = CMTime(value: 1, timescale: CMTimeScale(frameRate))
|
|
478
|
+
device.activeVideoMinFrameDuration = target
|
|
479
|
+
device.activeVideoMaxFrameDuration = target
|
|
480
|
+
device.unlockForConfiguration()
|
|
481
|
+
} catch {
|
|
482
|
+
print("Failed to set recording frame rate: \(error)")
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
let tempDir = FileManager.default.temporaryDirectory
|
|
487
|
+
let fileName = "video_\(Date().timeIntervalSince1970).mov"
|
|
488
|
+
let outputURL = tempDir.appendingPathComponent(fileName)
|
|
489
|
+
|
|
490
|
+
pendingVideoCall = call
|
|
491
|
+
isRecording = true
|
|
492
|
+
recordingStartTime = Date()
|
|
493
|
+
|
|
494
|
+
movieOutput.startRecording(to: outputURL, recordingDelegate: self)
|
|
495
|
+
|
|
496
|
+
// Periodic recording-state updates (~2/s).
|
|
497
|
+
recordingTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
|
|
498
|
+
guard let self = self, self.isRecording, let startTime = self.recordingStartTime else { return }
|
|
499
|
+
let duration = Date().timeIntervalSince(startTime)
|
|
500
|
+
let fileSize = self.movieOutput?.recordedFileSize ?? 0
|
|
501
|
+
self.notifyListeners("recordingState", data: [
|
|
502
|
+
"isRecording": true,
|
|
503
|
+
"duration": duration,
|
|
504
|
+
"fileSize": fileSize,
|
|
505
|
+
])
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
notifyListeners("recordingState", data: [
|
|
509
|
+
"isRecording": true,
|
|
510
|
+
"duration": 0,
|
|
511
|
+
"fileSize": 0,
|
|
512
|
+
])
|
|
513
|
+
|
|
514
|
+
call.resolve()
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
private func addAudioInput() {
|
|
518
|
+
guard let session = captureSession else { return }
|
|
519
|
+
guard let audioDevice = AVCaptureDevice.default(for: .audio) else { return }
|
|
520
|
+
do {
|
|
521
|
+
let audioInput = try AVCaptureDeviceInput(device: audioDevice)
|
|
522
|
+
if session.canAddInput(audioInput) {
|
|
523
|
+
session.addInput(audioInput)
|
|
524
|
+
}
|
|
525
|
+
} catch {
|
|
526
|
+
print("Could not add audio input: \(error)")
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// MARK: - stopRecording
|
|
531
|
+
|
|
532
|
+
@objc func stopRecording(_ call: CAPPluginCall) {
|
|
533
|
+
guard isRecording, let movieOutput = self.movieOutput else {
|
|
534
|
+
call.reject("Not recording")
|
|
535
|
+
return
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
pendingVideoCall = call
|
|
539
|
+
recordingTimer?.invalidate()
|
|
540
|
+
recordingTimer = nil
|
|
541
|
+
movieOutput.stopRecording()
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// MARK: - getRecordingState
|
|
545
|
+
|
|
546
|
+
@objc func getRecordingState(_ call: CAPPluginCall) {
|
|
547
|
+
let duration = recordingStartTime.map { Date().timeIntervalSince($0) } ?? 0
|
|
548
|
+
let fileSize = movieOutput?.recordedFileSize ?? 0
|
|
549
|
+
|
|
550
|
+
call.resolve([
|
|
551
|
+
"isRecording": isRecording,
|
|
552
|
+
"duration": duration,
|
|
553
|
+
"fileSize": fileSize,
|
|
554
|
+
])
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// MARK: - getSettings
|
|
558
|
+
|
|
559
|
+
@objc func getSettings(_ call: CAPPluginCall) {
|
|
560
|
+
var settings = currentSettings
|
|
561
|
+
|
|
562
|
+
// Read live values from device when available.
|
|
563
|
+
if let device = currentDevice {
|
|
564
|
+
settings["zoom"] = Double(device.videoZoomFactor)
|
|
565
|
+
|
|
566
|
+
if device.hasTorch && device.torchMode == .on {
|
|
567
|
+
settings["flash"] = "torch"
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Live ISO.
|
|
571
|
+
settings["iso"] = Double(device.iso)
|
|
572
|
+
|
|
573
|
+
// Live shutter speed (exposure duration in seconds).
|
|
574
|
+
let duration = device.exposureDuration
|
|
575
|
+
if duration.timescale > 0 {
|
|
576
|
+
settings["shutterSpeed"] = Double(duration.value) / Double(duration.timescale)
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
call.resolve(["settings": settings])
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// MARK: - setSettings
|
|
584
|
+
|
|
585
|
+
@objc func setSettings(_ call: CAPPluginCall) {
|
|
586
|
+
guard let settingsObj = call.getObject("settings") else {
|
|
587
|
+
call.reject("Missing settings parameter")
|
|
588
|
+
return
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// Persist all incoming keys.
|
|
592
|
+
for (key, value) in settingsObj {
|
|
593
|
+
currentSettings[key] = value
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
guard let device = currentDevice else {
|
|
597
|
+
call.resolve()
|
|
598
|
+
return
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
do {
|
|
602
|
+
try device.lockForConfiguration()
|
|
603
|
+
|
|
604
|
+
// Zoom.
|
|
605
|
+
if let zoom = settingsObj["zoom"] as? Double {
|
|
606
|
+
let clamped = max(1.0, min(device.maxAvailableVideoZoomFactor, CGFloat(zoom)))
|
|
607
|
+
device.videoZoomFactor = clamped
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// Flash / torch.
|
|
611
|
+
if let flash = settingsObj["flash"] as? String {
|
|
612
|
+
applyFlashOrTorch(flash, device: device)
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// Focus mode.
|
|
616
|
+
if let focus = settingsObj["focusMode"] as? String {
|
|
617
|
+
applyFocusMode(focus, device: device)
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// Exposure mode.
|
|
621
|
+
if let exposure = settingsObj["exposureMode"] as? String {
|
|
622
|
+
applyExposureMode(exposure, device: device)
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// Exposure compensation.
|
|
626
|
+
if let compensation = settingsObj["exposureCompensation"] as? Double {
|
|
627
|
+
let clamped = max(
|
|
628
|
+
Double(device.minExposureTargetBias),
|
|
629
|
+
min(Double(device.maxExposureTargetBias), compensation)
|
|
630
|
+
)
|
|
631
|
+
device.setExposureTargetBias(Float(clamped), completionHandler: nil)
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// White balance.
|
|
635
|
+
if let wb = settingsObj["whiteBalance"] as? String {
|
|
636
|
+
applyWhiteBalance(wb, device: device)
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// ISO (requires custom exposure mode).
|
|
640
|
+
if let iso = settingsObj["iso"] as? Double {
|
|
641
|
+
let clampedISO = Float(max(
|
|
642
|
+
Double(device.activeFormat.minISO),
|
|
643
|
+
min(Double(device.activeFormat.maxISO), iso)
|
|
644
|
+
))
|
|
645
|
+
device.setExposureModeCustom(
|
|
646
|
+
duration: device.exposureDuration,
|
|
647
|
+
iso: clampedISO,
|
|
648
|
+
completionHandler: nil
|
|
649
|
+
)
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// Shutter speed in seconds (requires custom exposure mode).
|
|
653
|
+
if let speed = settingsObj["shutterSpeed"] as? Double {
|
|
654
|
+
let minSec = CMTimeGetSeconds(device.activeFormat.minExposureDuration)
|
|
655
|
+
let maxSec = CMTimeGetSeconds(device.activeFormat.maxExposureDuration)
|
|
656
|
+
let clampedSpeed = max(minSec, min(maxSec, speed))
|
|
657
|
+
let duration = CMTime(seconds: clampedSpeed, preferredTimescale: 1_000_000)
|
|
658
|
+
device.setExposureModeCustom(
|
|
659
|
+
duration: duration,
|
|
660
|
+
iso: device.iso,
|
|
661
|
+
completionHandler: nil
|
|
662
|
+
)
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
device.unlockForConfiguration()
|
|
666
|
+
call.resolve()
|
|
667
|
+
} catch {
|
|
668
|
+
call.reject("Failed to apply settings: \(error.localizedDescription)")
|
|
669
|
+
emitError(code: "SETTINGS_FAILED", message: error.localizedDescription)
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// MARK: - Settings helpers
|
|
674
|
+
|
|
675
|
+
/// Apply flash mode or enable torch. Device must already be locked for configuration.
|
|
676
|
+
private func applyFlashOrTorch(_ mode: String, device: AVCaptureDevice) {
|
|
677
|
+
if mode == "torch" {
|
|
678
|
+
if device.hasTorch {
|
|
679
|
+
do {
|
|
680
|
+
try device.setTorchModeOn(level: AVCaptureDevice.maxAvailableTorchLevel)
|
|
681
|
+
} catch {
|
|
682
|
+
print("Failed to enable torch: \(error)")
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
} else {
|
|
686
|
+
// Turn off torch when switching to a regular flash mode.
|
|
687
|
+
if device.hasTorch && device.torchMode == .on {
|
|
688
|
+
device.torchMode = .off
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/// Apply focus mode. Device must already be locked for configuration.
|
|
694
|
+
private func applyFocusMode(_ mode: String, device: AVCaptureDevice) {
|
|
695
|
+
switch mode {
|
|
696
|
+
case "auto":
|
|
697
|
+
if device.isFocusModeSupported(.autoFocus) { device.focusMode = .autoFocus }
|
|
698
|
+
case "continuous":
|
|
699
|
+
if device.isFocusModeSupported(.continuousAutoFocus) { device.focusMode = .continuousAutoFocus }
|
|
700
|
+
case "manual":
|
|
701
|
+
if device.isFocusModeSupported(.locked) { device.focusMode = .locked }
|
|
702
|
+
default:
|
|
703
|
+
break
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/// Apply exposure mode. Device must already be locked for configuration.
|
|
708
|
+
private func applyExposureMode(_ mode: String, device: AVCaptureDevice) {
|
|
709
|
+
switch mode {
|
|
710
|
+
case "auto":
|
|
711
|
+
if device.isExposureModeSupported(.autoExpose) { device.exposureMode = .autoExpose }
|
|
712
|
+
case "continuous":
|
|
713
|
+
if device.isExposureModeSupported(.continuousAutoExposure) { device.exposureMode = .continuousAutoExposure }
|
|
714
|
+
case "manual":
|
|
715
|
+
if device.isExposureModeSupported(.custom) { device.exposureMode = .custom }
|
|
716
|
+
default:
|
|
717
|
+
break
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/// Apply white balance preset. Device must already be locked for configuration.
|
|
722
|
+
private func applyWhiteBalance(_ preset: String, device: AVCaptureDevice) {
|
|
723
|
+
switch preset {
|
|
724
|
+
case "auto":
|
|
725
|
+
if device.isWhiteBalanceModeSupported(.continuousAutoWhiteBalance) {
|
|
726
|
+
device.whiteBalanceMode = .continuousAutoWhiteBalance
|
|
727
|
+
}
|
|
728
|
+
case "daylight", "cloudy", "tungsten", "fluorescent":
|
|
729
|
+
if device.isWhiteBalanceModeSupported(.locked) {
|
|
730
|
+
let temperature: Float
|
|
731
|
+
switch preset {
|
|
732
|
+
case "daylight": temperature = 5500
|
|
733
|
+
case "cloudy": temperature = 6500
|
|
734
|
+
case "tungsten": temperature = 3200
|
|
735
|
+
case "fluorescent": temperature = 4000
|
|
736
|
+
default: temperature = 5500
|
|
737
|
+
}
|
|
738
|
+
let tempTint = AVCaptureDevice.WhiteBalanceTemperatureAndTintValues(
|
|
739
|
+
temperature: temperature, tint: 0
|
|
740
|
+
)
|
|
741
|
+
let gains = device.deviceWhiteBalanceGains(for: tempTint)
|
|
742
|
+
let clamped = clampWhiteBalanceGains(gains, for: device)
|
|
743
|
+
device.setWhiteBalanceModeLocked(with: clamped, completionHandler: nil)
|
|
744
|
+
}
|
|
745
|
+
default:
|
|
746
|
+
break
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/// Clamp white balance gains to the device's valid range.
|
|
751
|
+
private func clampWhiteBalanceGains(
|
|
752
|
+
_ gains: AVCaptureDevice.WhiteBalanceGains,
|
|
753
|
+
for device: AVCaptureDevice
|
|
754
|
+
) -> AVCaptureDevice.WhiteBalanceGains {
|
|
755
|
+
let maxGain = device.maxWhiteBalanceGain
|
|
756
|
+
return AVCaptureDevice.WhiteBalanceGains(
|
|
757
|
+
redGain: max(1.0, min(maxGain, gains.redGain)),
|
|
758
|
+
greenGain: max(1.0, min(maxGain, gains.greenGain)),
|
|
759
|
+
blueGain: max(1.0, min(maxGain, gains.blueGain))
|
|
760
|
+
)
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// MARK: - setZoom
|
|
764
|
+
|
|
765
|
+
@objc func setZoom(_ call: CAPPluginCall) {
|
|
766
|
+
guard let zoom = call.getDouble("zoom") else {
|
|
767
|
+
call.reject("Missing zoom parameter")
|
|
768
|
+
return
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
applyZoom(zoom)
|
|
772
|
+
currentSettings["zoom"] = zoom
|
|
773
|
+
call.resolve()
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
private func applyZoom(_ zoom: Double) {
|
|
777
|
+
guard let device = currentDevice else { return }
|
|
778
|
+
do {
|
|
779
|
+
try device.lockForConfiguration()
|
|
780
|
+
let clamped = max(1.0, min(device.maxAvailableVideoZoomFactor, CGFloat(zoom)))
|
|
781
|
+
device.videoZoomFactor = clamped
|
|
782
|
+
device.unlockForConfiguration()
|
|
783
|
+
} catch {
|
|
784
|
+
print("Failed to set zoom: \(error)")
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// MARK: - setFocusPoint
|
|
789
|
+
|
|
790
|
+
@objc func setFocusPoint(_ call: CAPPluginCall) {
|
|
791
|
+
guard let x = call.getDouble("x"), let y = call.getDouble("y") else {
|
|
792
|
+
call.reject("Missing focus point coordinates")
|
|
793
|
+
return
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
guard let device = currentDevice, device.isFocusPointOfInterestSupported else {
|
|
797
|
+
call.reject("Focus point not supported")
|
|
798
|
+
return
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
do {
|
|
802
|
+
try device.lockForConfiguration()
|
|
803
|
+
device.focusPointOfInterest = CGPoint(x: x, y: y)
|
|
804
|
+
device.focusMode = .autoFocus
|
|
805
|
+
device.unlockForConfiguration()
|
|
806
|
+
call.resolve()
|
|
807
|
+
} catch {
|
|
808
|
+
call.reject("Failed to set focus point: \(error.localizedDescription)")
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// MARK: - setExposurePoint
|
|
813
|
+
|
|
814
|
+
@objc func setExposurePoint(_ call: CAPPluginCall) {
|
|
815
|
+
guard let x = call.getDouble("x"), let y = call.getDouble("y") else {
|
|
816
|
+
call.reject("Missing exposure point coordinates")
|
|
817
|
+
return
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
guard let device = currentDevice, device.isExposurePointOfInterestSupported else {
|
|
821
|
+
call.reject("Exposure point not supported")
|
|
822
|
+
return
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
do {
|
|
826
|
+
try device.lockForConfiguration()
|
|
827
|
+
device.exposurePointOfInterest = CGPoint(x: x, y: y)
|
|
828
|
+
device.exposureMode = .autoExpose
|
|
829
|
+
device.unlockForConfiguration()
|
|
830
|
+
call.resolve()
|
|
831
|
+
} catch {
|
|
832
|
+
call.reject("Failed to set exposure point: \(error.localizedDescription)")
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// MARK: - Permissions
|
|
837
|
+
|
|
838
|
+
@objc public override func checkPermissions(_ call: CAPPluginCall) {
|
|
839
|
+
let cameraStatus = AVCaptureDevice.authorizationStatus(for: .video)
|
|
840
|
+
let micStatus = AVCaptureDevice.authorizationStatus(for: .audio)
|
|
841
|
+
let photosStatus = PHPhotoLibrary.authorizationStatus()
|
|
842
|
+
|
|
843
|
+
call.resolve([
|
|
844
|
+
"camera": permissionString(from: cameraStatus),
|
|
845
|
+
"microphone": permissionString(from: micStatus),
|
|
846
|
+
"photos": photosPermissionString(from: photosStatus),
|
|
847
|
+
])
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
@objc public override func requestPermissions(_ call: CAPPluginCall) {
|
|
851
|
+
let group = DispatchGroup()
|
|
852
|
+
|
|
853
|
+
var cameraResult: AVAuthorizationStatus = .notDetermined
|
|
854
|
+
var micResult: AVAuthorizationStatus = .notDetermined
|
|
855
|
+
var photosResult: PHAuthorizationStatus = .notDetermined
|
|
856
|
+
|
|
857
|
+
group.enter()
|
|
858
|
+
AVCaptureDevice.requestAccess(for: .video) { _ in
|
|
859
|
+
cameraResult = AVCaptureDevice.authorizationStatus(for: .video)
|
|
860
|
+
group.leave()
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
group.enter()
|
|
864
|
+
AVCaptureDevice.requestAccess(for: .audio) { _ in
|
|
865
|
+
micResult = AVCaptureDevice.authorizationStatus(for: .audio)
|
|
866
|
+
group.leave()
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
group.enter()
|
|
870
|
+
PHPhotoLibrary.requestAuthorization { status in
|
|
871
|
+
photosResult = status
|
|
872
|
+
group.leave()
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
group.notify(queue: .main) { [weak self] in
|
|
876
|
+
guard let self = self else { return }
|
|
877
|
+
call.resolve([
|
|
878
|
+
"camera": self.permissionString(from: cameraResult),
|
|
879
|
+
"microphone": self.permissionString(from: micResult),
|
|
880
|
+
"photos": self.photosPermissionString(from: photosResult),
|
|
881
|
+
])
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
private func permissionString(from status: AVAuthorizationStatus) -> String {
|
|
886
|
+
switch status {
|
|
887
|
+
case .authorized: return "granted"
|
|
888
|
+
case .denied, .restricted: return "denied"
|
|
889
|
+
default: return "prompt"
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
private func photosPermissionString(from status: PHAuthorizationStatus) -> String {
|
|
894
|
+
switch status {
|
|
895
|
+
case .authorized: return "granted"
|
|
896
|
+
case .limited: return "limited"
|
|
897
|
+
case .denied, .restricted: return "denied"
|
|
898
|
+
default: return "prompt"
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
// MARK: - EXIF Extraction
|
|
903
|
+
|
|
904
|
+
/// Extract EXIF metadata from a captured photo for the PhotoResult.exif field.
|
|
905
|
+
private func extractExifMetadata(from photo: AVCapturePhoto) -> [String: Any]? {
|
|
906
|
+
let metadata = photo.metadata
|
|
907
|
+
var exif: [String: Any] = [:]
|
|
908
|
+
|
|
909
|
+
if let exifDict = metadata[kCGImagePropertyExifDictionary as String] as? [String: Any] {
|
|
910
|
+
if let v = exifDict[kCGImagePropertyExifExposureTime as String] { exif["ExposureTime"] = v }
|
|
911
|
+
if let v = exifDict[kCGImagePropertyExifFNumber as String] { exif["FNumber"] = v }
|
|
912
|
+
if let isos = exifDict[kCGImagePropertyExifISOSpeedRatings as String] as? [Int],
|
|
913
|
+
let iso = isos.first { exif["ISO"] = iso }
|
|
914
|
+
if let v = exifDict[kCGImagePropertyExifFocalLength as String] { exif["FocalLength"] = v }
|
|
915
|
+
if let v = exifDict[kCGImagePropertyExifLensModel as String] { exif["LensModel"] = v }
|
|
916
|
+
if let v = exifDict[kCGImagePropertyExifDateTimeOriginal as String] { exif["DateTimeOriginal"] = v }
|
|
917
|
+
if let v = exifDict[kCGImagePropertyExifBrightnessValue as String] { exif["BrightnessValue"] = v }
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
if let tiffDict = metadata[kCGImagePropertyTIFFDictionary as String] as? [String: Any] {
|
|
921
|
+
if let v = tiffDict[kCGImagePropertyTIFFMake as String] { exif["Make"] = v }
|
|
922
|
+
if let v = tiffDict[kCGImagePropertyTIFFModel as String] { exif["Model"] = v }
|
|
923
|
+
if let v = tiffDict[kCGImagePropertyTIFFOrientation as String] { exif["Orientation"] = v }
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
if let gpsDict = metadata[kCGImagePropertyGPSDictionary as String] as? [String: Any] {
|
|
927
|
+
if let v = gpsDict[kCGImagePropertyGPSLatitude as String] { exif["GPSLatitude"] = v }
|
|
928
|
+
if let v = gpsDict[kCGImagePropertyGPSLongitude as String] { exif["GPSLongitude"] = v }
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
return exif.isEmpty ? nil : exif
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// MARK: - MP4 Export (ported from classic CameraController.exportToMP4)
|
|
935
|
+
|
|
936
|
+
/// Transcode .mov to .mp4 for easier downstream handling.
|
|
937
|
+
private func exportToMP4(inputURL: URL, completion: @escaping (Result<URL, Error>) -> Void) {
|
|
938
|
+
let mp4URL = FileManager.default.temporaryDirectory
|
|
939
|
+
.appendingPathComponent("video_\(Date().timeIntervalSince1970).mp4")
|
|
940
|
+
|
|
941
|
+
let asset = AVURLAsset(url: inputURL)
|
|
942
|
+
guard let exporter = AVAssetExportSession(
|
|
943
|
+
asset: asset,
|
|
944
|
+
presetName: AVAssetExportPresetHighestQuality
|
|
945
|
+
) else {
|
|
946
|
+
completion(.failure(NSError(domain: "ElizaCamera", code: 1, userInfo: [
|
|
947
|
+
NSLocalizedDescriptionKey: "Failed to create export session",
|
|
948
|
+
])))
|
|
949
|
+
return
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
exporter.outputURL = mp4URL
|
|
953
|
+
exporter.outputFileType = .mp4
|
|
954
|
+
exporter.shouldOptimizeForNetworkUse = true
|
|
955
|
+
|
|
956
|
+
exporter.exportAsynchronously {
|
|
957
|
+
switch exporter.status {
|
|
958
|
+
case .completed:
|
|
959
|
+
completion(.success(mp4URL))
|
|
960
|
+
case .failed:
|
|
961
|
+
completion(.failure(exporter.error ?? NSError(domain: "ElizaCamera", code: 2, userInfo: [
|
|
962
|
+
NSLocalizedDescriptionKey: "Export failed",
|
|
963
|
+
])))
|
|
964
|
+
case .cancelled:
|
|
965
|
+
completion(.failure(NSError(domain: "ElizaCamera", code: 3, userInfo: [
|
|
966
|
+
NSLocalizedDescriptionKey: "Export cancelled",
|
|
967
|
+
])))
|
|
968
|
+
default:
|
|
969
|
+
completion(.failure(NSError(domain: "ElizaCamera", code: 4, userInfo: [
|
|
970
|
+
NSLocalizedDescriptionKey: "Export did not complete",
|
|
971
|
+
])))
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
// MARK: - AVCapturePhotoCaptureDelegate
|
|
978
|
+
|
|
979
|
+
extension ElizaCameraPlugin: AVCapturePhotoCaptureDelegate {
|
|
980
|
+
public func photoOutput(
|
|
981
|
+
_ output: AVCapturePhotoOutput,
|
|
982
|
+
didFinishProcessingPhoto photo: AVCapturePhoto,
|
|
983
|
+
error: Error?
|
|
984
|
+
) {
|
|
985
|
+
guard let call = pendingPhotoCall else { return }
|
|
986
|
+
pendingPhotoCall = nil
|
|
987
|
+
|
|
988
|
+
if let error = error {
|
|
989
|
+
call.reject("Photo capture failed: \(error.localizedDescription)")
|
|
990
|
+
emitError(code: "CAPTURE_FAILED", message: error.localizedDescription)
|
|
991
|
+
return
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
guard let imageData = photo.fileDataRepresentation() else {
|
|
995
|
+
call.reject("Failed to get image data")
|
|
996
|
+
emitError(code: "CAPTURE_FAILED", message: "Failed to get image data")
|
|
997
|
+
return
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
guard let image = UIImage(data: imageData) else {
|
|
1001
|
+
call.reject("Failed to create image")
|
|
1002
|
+
return
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
let options = currentPhotoOptions ?? [:]
|
|
1006
|
+
let rawQuality = options["quality"] as? Float ?? 90
|
|
1007
|
+
let quality = Self.clampQuality(rawQuality)
|
|
1008
|
+
let format = options["format"] as? String ?? "jpeg"
|
|
1009
|
+
let saveToGallery = options["saveToGallery"] as? Bool ?? false
|
|
1010
|
+
let targetWidth = options["width"] as? Int
|
|
1011
|
+
let targetHeight = options["height"] as? Int
|
|
1012
|
+
let includeExif = options["includeExif"] as? Bool ?? true
|
|
1013
|
+
|
|
1014
|
+
var finalImage = image
|
|
1015
|
+
|
|
1016
|
+
// Resize if target dimensions are specified.
|
|
1017
|
+
if let width = targetWidth, let height = targetHeight {
|
|
1018
|
+
let size = CGSize(width: width, height: height)
|
|
1019
|
+
UIGraphicsBeginImageContextWithOptions(size, false, 1.0)
|
|
1020
|
+
image.draw(in: CGRect(origin: .zero, size: size))
|
|
1021
|
+
if let resized = UIGraphicsGetImageFromCurrentImageContext() {
|
|
1022
|
+
finalImage = resized
|
|
1023
|
+
}
|
|
1024
|
+
UIGraphicsEndImageContext()
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
var outputData: Data?
|
|
1028
|
+
var outputFormat = format
|
|
1029
|
+
|
|
1030
|
+
switch format {
|
|
1031
|
+
case "png":
|
|
1032
|
+
outputData = finalImage.pngData()
|
|
1033
|
+
outputFormat = "png"
|
|
1034
|
+
case "webp":
|
|
1035
|
+
// iOS has no native WebP encoder; fall back to JPEG.
|
|
1036
|
+
outputData = finalImage.jpegData(compressionQuality: CGFloat(quality))
|
|
1037
|
+
outputFormat = "jpeg"
|
|
1038
|
+
default:
|
|
1039
|
+
outputData = finalImage.jpegData(compressionQuality: CGFloat(quality))
|
|
1040
|
+
outputFormat = "jpeg"
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
guard let data = outputData else {
|
|
1044
|
+
call.reject("Failed to encode image")
|
|
1045
|
+
return
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
// Save to temp file for the `path` field in PhotoResult.
|
|
1049
|
+
let ext = outputFormat == "png" ? "png" : "jpg"
|
|
1050
|
+
let tempPath = FileManager.default.temporaryDirectory
|
|
1051
|
+
.appendingPathComponent("photo_\(Date().timeIntervalSince1970).\(ext)")
|
|
1052
|
+
try? data.write(to: tempPath)
|
|
1053
|
+
|
|
1054
|
+
if saveToGallery {
|
|
1055
|
+
PHPhotoLibrary.shared().performChanges({
|
|
1056
|
+
let request = PHAssetCreationRequest.forAsset()
|
|
1057
|
+
request.addResource(with: .photo, data: data, options: nil)
|
|
1058
|
+
}, completionHandler: nil)
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
let base64 = data.base64EncodedString()
|
|
1062
|
+
|
|
1063
|
+
var result: [String: Any] = [
|
|
1064
|
+
"base64": base64,
|
|
1065
|
+
"format": outputFormat,
|
|
1066
|
+
"width": Int(finalImage.size.width),
|
|
1067
|
+
"height": Int(finalImage.size.height),
|
|
1068
|
+
"path": tempPath.absoluteString,
|
|
1069
|
+
]
|
|
1070
|
+
|
|
1071
|
+
// Include EXIF metadata if requested.
|
|
1072
|
+
if includeExif, let exif = extractExifMetadata(from: photo) {
|
|
1073
|
+
result["exif"] = exif
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
call.resolve(result)
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
// MARK: - AVCaptureFileOutputRecordingDelegate
|
|
1081
|
+
|
|
1082
|
+
extension ElizaCameraPlugin: AVCaptureFileOutputRecordingDelegate {
|
|
1083
|
+
public func fileOutput(
|
|
1084
|
+
_ output: AVCaptureFileOutput,
|
|
1085
|
+
didFinishRecordingTo outputFileURL: URL,
|
|
1086
|
+
from connections: [AVCaptureConnection],
|
|
1087
|
+
error: Error?
|
|
1088
|
+
) {
|
|
1089
|
+
isRecording = false
|
|
1090
|
+
recordingTimer?.invalidate()
|
|
1091
|
+
recordingTimer = nil
|
|
1092
|
+
|
|
1093
|
+
guard let call = pendingVideoCall else { return }
|
|
1094
|
+
pendingVideoCall = nil
|
|
1095
|
+
|
|
1096
|
+
// Treat max-duration/max-file-size reached as success (ported from classic).
|
|
1097
|
+
if let error = error {
|
|
1098
|
+
let ns = error as NSError
|
|
1099
|
+
let isExpectedStop = ns.domain == AVFoundationErrorDomain
|
|
1100
|
+
&& (ns.code == AVError.maximumDurationReached.rawValue
|
|
1101
|
+
|| ns.code == AVError.maximumFileSizeReached.rawValue)
|
|
1102
|
+
if !isExpectedStop {
|
|
1103
|
+
call.reject("Recording failed: \(error.localizedDescription)")
|
|
1104
|
+
emitError(code: "RECORDING_FAILED", message: error.localizedDescription)
|
|
1105
|
+
try? FileManager.default.removeItem(at: outputFileURL)
|
|
1106
|
+
return
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
let duration = recordingStartTime.map { Date().timeIntervalSince($0) } ?? 0
|
|
1111
|
+
let options = currentVideoOptions ?? [:]
|
|
1112
|
+
let saveToGallery = options["saveToGallery"] as? Bool ?? false
|
|
1113
|
+
|
|
1114
|
+
// Transcode .mov -> .mp4 (ported from classic CameraController).
|
|
1115
|
+
exportToMP4(inputURL: outputFileURL) { [weak self] result in
|
|
1116
|
+
guard let self = self else { return }
|
|
1117
|
+
|
|
1118
|
+
switch result {
|
|
1119
|
+
case .success(let mp4URL):
|
|
1120
|
+
// Clean up the original .mov.
|
|
1121
|
+
try? FileManager.default.removeItem(at: outputFileURL)
|
|
1122
|
+
|
|
1123
|
+
var fileSize: Int64 = 0
|
|
1124
|
+
var width = 0
|
|
1125
|
+
var height = 0
|
|
1126
|
+
|
|
1127
|
+
if let attrs = try? FileManager.default.attributesOfItem(atPath: mp4URL.path) {
|
|
1128
|
+
fileSize = attrs[.size] as? Int64 ?? 0
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
let asset = AVAsset(url: mp4URL)
|
|
1132
|
+
if let track = asset.tracks(withMediaType: .video).first {
|
|
1133
|
+
let size = track.naturalSize.applying(track.preferredTransform)
|
|
1134
|
+
width = Int(abs(size.width))
|
|
1135
|
+
height = Int(abs(size.height))
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
if saveToGallery {
|
|
1139
|
+
PHPhotoLibrary.shared().performChanges({
|
|
1140
|
+
PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: mp4URL)
|
|
1141
|
+
}, completionHandler: nil)
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
self.notifyListeners("recordingState", data: [
|
|
1145
|
+
"isRecording": false,
|
|
1146
|
+
"duration": duration,
|
|
1147
|
+
"fileSize": fileSize,
|
|
1148
|
+
])
|
|
1149
|
+
|
|
1150
|
+
call.resolve([
|
|
1151
|
+
"path": mp4URL.absoluteString,
|
|
1152
|
+
"duration": duration,
|
|
1153
|
+
"width": width,
|
|
1154
|
+
"height": height,
|
|
1155
|
+
"fileSize": fileSize,
|
|
1156
|
+
"mimeType": "video/mp4",
|
|
1157
|
+
])
|
|
1158
|
+
|
|
1159
|
+
case .failure:
|
|
1160
|
+
// MP4 export failed; fall back to the original .mov file.
|
|
1161
|
+
var fileSize: Int64 = 0
|
|
1162
|
+
var width = 0
|
|
1163
|
+
var height = 0
|
|
1164
|
+
|
|
1165
|
+
if let attrs = try? FileManager.default.attributesOfItem(atPath: outputFileURL.path) {
|
|
1166
|
+
fileSize = attrs[.size] as? Int64 ?? 0
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
let asset = AVAsset(url: outputFileURL)
|
|
1170
|
+
if let track = asset.tracks(withMediaType: .video).first {
|
|
1171
|
+
let size = track.naturalSize.applying(track.preferredTransform)
|
|
1172
|
+
width = Int(abs(size.width))
|
|
1173
|
+
height = Int(abs(size.height))
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
if saveToGallery {
|
|
1177
|
+
PHPhotoLibrary.shared().performChanges({
|
|
1178
|
+
PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: outputFileURL)
|
|
1179
|
+
}, completionHandler: nil)
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
self.notifyListeners("recordingState", data: [
|
|
1183
|
+
"isRecording": false,
|
|
1184
|
+
"duration": duration,
|
|
1185
|
+
"fileSize": fileSize,
|
|
1186
|
+
])
|
|
1187
|
+
|
|
1188
|
+
call.resolve([
|
|
1189
|
+
"path": outputFileURL.absoluteString,
|
|
1190
|
+
"duration": duration,
|
|
1191
|
+
"width": width,
|
|
1192
|
+
"height": height,
|
|
1193
|
+
"fileSize": fileSize,
|
|
1194
|
+
"mimeType": "video/quicktime",
|
|
1195
|
+
])
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
// MARK: - AVCaptureVideoDataOutputSampleBufferDelegate (Frame Events)
|
|
1202
|
+
|
|
1203
|
+
extension ElizaCameraPlugin: AVCaptureVideoDataOutputSampleBufferDelegate {
|
|
1204
|
+
public func captureOutput(
|
|
1205
|
+
_ output: AVCaptureOutput,
|
|
1206
|
+
didOutput sampleBuffer: CMSampleBuffer,
|
|
1207
|
+
from connection: AVCaptureConnection
|
|
1208
|
+
) {
|
|
1209
|
+
// Throttle frame events to ~2/s to avoid overwhelming the JS bridge.
|
|
1210
|
+
let now = CFAbsoluteTimeGetCurrent()
|
|
1211
|
+
guard now - lastFrameEmitTime >= 0.5 else { return }
|
|
1212
|
+
lastFrameEmitTime = now
|
|
1213
|
+
|
|
1214
|
+
guard let formatDesc = CMSampleBufferGetFormatDescription(sampleBuffer) else { return }
|
|
1215
|
+
let dims = CMVideoFormatDescriptionGetDimensions(formatDesc)
|
|
1216
|
+
let pts = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
|
|
1217
|
+
let timestampMs = CMTimeGetSeconds(pts).isFinite ? Int(CMTimeGetSeconds(pts) * 1000) : 0
|
|
1218
|
+
|
|
1219
|
+
notifyListeners("frame", data: [
|
|
1220
|
+
"timestamp": timestampMs,
|
|
1221
|
+
"width": Int(dims.width),
|
|
1222
|
+
"height": Int(dims.height),
|
|
1223
|
+
])
|
|
1224
|
+
}
|
|
1225
|
+
}
|