@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.
@@ -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
+ }