@octane-xplat/camera 0.0.0-nightly.g2e22171

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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +135 -0
  3. package/package.json +104 -0
  4. package/platforms/android/AndroidManifest.xml +6 -0
  5. package/platforms/android/include.gradle +14 -0
  6. package/platforms/android/java/com/octanexplat/camera/CameraPreviewView.java +84 -0
  7. package/platforms/android/java/com/octanexplat/camera/CameraSessionBridge.java +394 -0
  8. package/platforms/ios/Info.plist +10 -0
  9. package/platforms/macos/src/CameraOwnership.swift +22 -0
  10. package/platforms/macos/src/PhotoCaptureLifecycle.swift +34 -0
  11. package/platforms/macos/src/XplatCameraHost.swift +639 -0
  12. package/platforms/macos/src/XplatPhotoCaptureSheet.swift +192 -0
  13. package/src/CameraView.android.tsrx +150 -0
  14. package/src/CameraView.ios.tsrx +102 -0
  15. package/src/CameraView.macos.tsrx +95 -0
  16. package/src/CameraView.mobile.ts +6 -0
  17. package/src/CameraView.web.tsrx +132 -0
  18. package/src/CameraView.windows.tsrx +126 -0
  19. package/src/camera-permission.mobile.ts +91 -0
  20. package/src/camera-permission.windows.ts +76 -0
  21. package/src/camera-session.ios.ts +65 -0
  22. package/src/camera-session.macos.ts +75 -0
  23. package/src/camera-session.windows.ts +126 -0
  24. package/src/host-movie.web.ts +295 -0
  25. package/src/index.ts +36 -0
  26. package/src/index.web.ts +34 -0
  27. package/src/layout-child.ts +81 -0
  28. package/src/layout-child.web.ts +66 -0
  29. package/src/layout-child.windows.ts +79 -0
  30. package/src/movie-metadata.web.ts +84 -0
  31. package/src/movie-storage.web.ts +164 -0
  32. package/src/preview-tap.ts +34 -0
  33. package/src/props.ts +77 -0
  34. package/src/session-backend.android.ts +439 -0
  35. package/src/session-backend.ios.ts +1258 -0
  36. package/src/session-backend.linux.ts +887 -0
  37. package/src/session-backend.macos.ts +707 -0
  38. package/src/session-backend.ts +59 -0
  39. package/src/session-backend.web.ts +891 -0
  40. package/src/session-backend.windows.ts +1599 -0
  41. package/src/session-core.ts +723 -0
  42. package/src/session.ts +37 -0
  43. package/src/synthetic-session.ts +377 -0
  44. package/src/types.ts +357 -0
  45. package/types/index.d.ts +48 -0
  46. package/types/props.d.ts +61 -0
  47. package/types/session-core.d.ts +139 -0
  48. package/types/synthetic-session.d.ts +51 -0
  49. package/types/types.d.ts +290 -0
@@ -0,0 +1,639 @@
1
+ import AppKit
2
+ import AVFoundation
3
+ import CoreMedia
4
+
5
+ private func fourCharCode(_ value: FourCharCode) -> String {
6
+ String(
7
+ format: "%c%c%c%c",
8
+ Int((value >> 24) & 0xff),
9
+ Int((value >> 16) & 0xff),
10
+ Int((value >> 8) & 0xff),
11
+ Int(value & 0xff)
12
+ )
13
+ }
14
+
15
+ // Finalized-movie metadata read inside the delegate callback: the file is
16
+ // complete at that point, so AVFoundation reports real dimensions, duration,
17
+ // audio presence, and rotation instead of estimated state.
18
+ private func movieClipInfo(_ url: URL) -> [String: Any] {
19
+ let asset = AVURLAsset(url: url)
20
+ var width = 0.0
21
+ var height = 0.0
22
+ var rotation = 0.0
23
+ var videoCodec: String?
24
+
25
+ if let track = asset.tracks(withMediaType: .video).first {
26
+ let transformed = track.naturalSize.applying(track.preferredTransform)
27
+ width = abs(Double(transformed.width))
28
+ height = abs(Double(transformed.height))
29
+ rotation = atan2(Double(track.preferredTransform.b), Double(track.preferredTransform.a))
30
+ * 180.0 / .pi
31
+ if rotation < 0 {
32
+ rotation += 360
33
+ }
34
+ if let format = track.formatDescriptions.first as! CMFormatDescription? {
35
+ let subtype = fourCharCode(CMFormatDescriptionGetMediaSubType(format))
36
+ if subtype == "avc1" {
37
+ videoCodec = "avc1"
38
+ } else if subtype == "hvc1" || subtype == "hev1" {
39
+ videoCodec = "hvc1"
40
+ }
41
+ }
42
+ }
43
+
44
+ let duration = CMTimeGetSeconds(asset.duration)
45
+ let size = (try? FileManager.default.attributesOfItem(atPath: url.path))?[.size] as? Int ?? 0
46
+ var info: [String: Any] = [
47
+ "fileUrl": url.absoluteString,
48
+ "durationMs": duration.isFinite ? duration * 1000 : 0,
49
+ "width": width,
50
+ "height": height,
51
+ "rotationAngle": (Int((rotation / 90).rounded()) * 90) % 360,
52
+ "fileSize": size,
53
+ "hasAudio": !asset.tracks(withMediaType: .audio).isEmpty,
54
+ "mimeType": "video/quicktime",
55
+ "container": "mov",
56
+ ]
57
+ if let videoCodec {
58
+ info["videoCodec"] = videoCodec
59
+ }
60
+ return info
61
+ }
62
+
63
+ private final class XplatRecordingDelegate: NSObject, AVCaptureFileOutputRecordingDelegate {
64
+ var onStart: (() -> Void)?
65
+ var onFinish: ((URL, Bool, Bool, NSError?) -> Void)?
66
+
67
+ func fileOutput(
68
+ _ output: AVCaptureFileOutput,
69
+ didStartRecordingTo fileURL: URL,
70
+ from connections: [AVCaptureConnection]
71
+ ) {
72
+ DispatchQueue.main.async { self.onStart?() }
73
+ }
74
+
75
+ func fileOutput(
76
+ _ output: AVCaptureFileOutput,
77
+ didFinishRecordingTo outputFileURL: URL,
78
+ from connections: [AVCaptureConnection],
79
+ error: Error?
80
+ ) {
81
+ let failure = error as NSError?
82
+ // A requested stop finalizes cleanly; the maximum-duration limit and
83
+ // interruption-driven endings still produce usable files.
84
+ let succeeded =
85
+ failure == nil ||
86
+ (failure!.userInfo[AVErrorRecordingSuccessfullyFinishedKey] as? Bool == true)
87
+ let limitReached =
88
+ failure != nil && failure!.code == AVError.Code.maximumDurationReached.rawValue
89
+ DispatchQueue.main.async { self.onFinish?(outputFileURL, succeeded, limitReached, failure) }
90
+ }
91
+ }
92
+
93
+ private final class XplatPhotoDelegate: NSObject, AVCapturePhotoCaptureDelegate {
94
+ var reply: ((Data?) -> Void)?
95
+ func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) {
96
+ let data = error == nil ? photo.fileDataRepresentation() : nil
97
+ DispatchQueue.main.async { [self] in
98
+ reply?(data)
99
+ reply = nil
100
+ }
101
+ }
102
+ }
103
+
104
+ @objc(XplatCameraHost)
105
+ public final class XplatCameraHost: NSObject {
106
+ private var session: AVCaptureSession?
107
+ private var videoInput: AVCaptureDeviceInput?
108
+ private var audioInput: AVCaptureDeviceInput?
109
+ private var movieOutput: AVCaptureMovieFileOutput?
110
+ private var photoOutput: AVCapturePhotoOutput?
111
+ private var photoDelegate: XplatPhotoDelegate?
112
+ private var claimedCameraIds: Set<String> = []
113
+ private var previewLayer: AVCaptureVideoPreviewLayer?
114
+ private weak var previewView: NSView?
115
+ private var configuredCameraId: String?
116
+ private var configuredAudio = false
117
+ private var rotationAngle: Double?
118
+ private var emit: ((String) -> Void)?
119
+ private var observers: [NSObjectProtocol] = []
120
+ private var disposed = false
121
+ private var recordingPath: String?
122
+ private let queue = DispatchQueue(label: "org.octane.xplat.camera")
123
+
124
+ public override init() {
125
+ super.init()
126
+ }
127
+
128
+ private func send(_ payload: [String: Any]) {
129
+ guard
130
+ let emit,
131
+ let data = try? JSONSerialization.data(withJSONObject: payload),
132
+ let text = String(data: data, encoding: .utf8)
133
+ else {
134
+ return
135
+ }
136
+
137
+ emit(text)
138
+ }
139
+
140
+ @objc public func installObserver(_ listener: @escaping (String) -> Void) {
141
+ emit = listener
142
+ }
143
+
144
+ private func status(for mediaType: AVMediaType) -> String {
145
+ switch AVCaptureDevice.authorizationStatus(for: mediaType) {
146
+ case .authorized:
147
+ return "granted"
148
+ case .denied:
149
+ return "denied"
150
+ case .restricted:
151
+ return "restricted"
152
+ case .notDetermined:
153
+ return "notDetermined"
154
+ @unknown default:
155
+ return "unknown"
156
+ }
157
+ }
158
+
159
+ private func mediaKind(_ kind: String) -> AVMediaType {
160
+ kind == "microphone" ? .audio : .video
161
+ }
162
+
163
+ private func declared(_ mediaType: AVMediaType) -> Bool {
164
+ let key = mediaType == .audio ? "NSMicrophoneUsageDescription" : "NSCameraUsageDescription"
165
+ return Bundle.main.object(forInfoDictionaryKey: key) != nil
166
+ }
167
+
168
+ @objc public func permissionStatus(_ kind: String) -> NSString {
169
+ let mediaType = mediaKind(kind)
170
+ guard declared(mediaType) else { return "undeclared" }
171
+ return status(for: mediaType) as NSString
172
+ }
173
+
174
+ @objc public func requestPermission(_ kind: String, reply: @escaping (String) -> Void) {
175
+ let mediaType = mediaKind(kind)
176
+ guard declared(mediaType) else {
177
+ reply("undeclared")
178
+ return
179
+ }
180
+ let current = status(for: mediaType)
181
+ guard current == "notDetermined" else {
182
+ reply(current)
183
+ return
184
+ }
185
+ AVCaptureDevice.requestAccess(for: mediaType) { _ in
186
+ DispatchQueue.main.async { reply(self.status(for: mediaType)) }
187
+ }
188
+ }
189
+
190
+ private func cameraDevices() -> [AVCaptureDevice] {
191
+ let types: [AVCaptureDevice.DeviceType] = [.builtInWideAngleCamera, .externalUnknown]
192
+ var found = AVCaptureDevice.DiscoverySession(
193
+ deviceTypes: types,
194
+ mediaType: .video,
195
+ position: .unspecified
196
+ ).devices
197
+ // The discovery session is authoritative; keep the legacy enumerator as
198
+ // a merge source for devices on older system builds.
199
+ for device in AVCaptureDevice.devices(for: .video) where !found.contains(device) {
200
+ found.append(device)
201
+ }
202
+ return found
203
+ }
204
+
205
+ @objc public func devices() -> NSString {
206
+ let list: [[String: Any]] = cameraDevices().map {
207
+ [
208
+ "id": $0.uniqueID,
209
+ "name": $0.localizedName,
210
+ "facing": NSNull(),
211
+ "focusPointOfInterest": $0.isFocusPointOfInterestSupported,
212
+ "exposurePointOfInterest": $0.isExposurePointOfInterestSupported,
213
+ ]
214
+ }
215
+ guard
216
+ let data = try? JSONSerialization.data(withJSONObject: list),
217
+ let text = String(data: data, encoding: .utf8)
218
+ else {
219
+ return "[]"
220
+ }
221
+ return text as NSString
222
+ }
223
+
224
+ @objc public func audioDeviceAvailable() -> Bool {
225
+ !AVCaptureDevice.devices(for: .audio).isEmpty
226
+ }
227
+
228
+ private func ensureSession() -> AVCaptureSession? {
229
+ if let session {
230
+ return session
231
+ }
232
+ guard status(for: .video) == "granted" else {
233
+ return nil
234
+ }
235
+ let session = AVCaptureSession()
236
+ if session.canSetSessionPreset(.hd1280x720) {
237
+ session.sessionPreset = .hd1280x720
238
+ }
239
+ self.session = session
240
+ let names: [Notification.Name] = [
241
+ .AVCaptureSessionWasInterrupted,
242
+ .AVCaptureSessionInterruptionEnded,
243
+ .AVCaptureSessionRuntimeError,
244
+ .AVCaptureSessionDidStartRunning,
245
+ .AVCaptureSessionDidStopRunning,
246
+ ]
247
+ for name in names {
248
+ observers.append(
249
+ NotificationCenter.default.addObserver(
250
+ forName: name,
251
+ object: session,
252
+ queue: .main
253
+ ) { [weak self] note in
254
+ self?.handleSessionNotification(note)
255
+ }
256
+ )
257
+ }
258
+ for (name, available) in [
259
+ (NSNotification.Name.AVCaptureDeviceWasDisconnected, false),
260
+ (NSNotification.Name.AVCaptureDeviceWasConnected, true),
261
+ ] {
262
+ observers.append(
263
+ NotificationCenter.default.addObserver(
264
+ forName: name,
265
+ object: nil,
266
+ queue: .main
267
+ ) { [weak self] _ in
268
+ self?.send(["type": "availability", "available": available])
269
+ }
270
+ )
271
+ }
272
+ return session
273
+ }
274
+
275
+ private func handleSessionNotification(_ note: Notification) {
276
+ switch note.name {
277
+ case .AVCaptureSessionWasInterrupted:
278
+ // macOS publishes no interruption-reason keys — the cause stays
279
+ // honest 'unknown' rather than a guessed code.
280
+ send(["type": "interrupted", "cause": "unknown"])
281
+ case .AVCaptureSessionInterruptionEnded:
282
+ send(["type": "interruptionEnded"])
283
+ case .AVCaptureSessionRuntimeError:
284
+ send(["type": "availability", "available": false])
285
+ send(["type": "interrupted", "cause": "cameraInUse"])
286
+ case .AVCaptureSessionDidStartRunning:
287
+ send(["type": "previewReady", "ready": true])
288
+ case .AVCaptureSessionDidStopRunning:
289
+ send(["type": "previewReady", "ready": false])
290
+ default:
291
+ break
292
+ }
293
+ }
294
+
295
+ /** Rotation-angle connections exist on macOS 14+; earlier hosts cannot
296
+ * lock a take's orientation. */
297
+ @objc public func supportsRotationAngles() -> Bool {
298
+ if #available(macOS 14.0, *) {
299
+ return true
300
+ }
301
+ return false
302
+ }
303
+
304
+ private func applyRotation(_ connection: AVCaptureConnection?) {
305
+ guard let connection, let rotationAngle else { return }
306
+ if #available(macOS 14.0, *),
307
+ connection.isVideoRotationAngleSupported(rotationAngle)
308
+ {
309
+ connection.videoRotationAngle = rotationAngle
310
+ }
311
+ }
312
+
313
+ @objc public func configure(_ options: NSDictionary) -> NSString? {
314
+ guard !disposed else { return "unavailable" }
315
+ guard let session = ensureSession() else { return "permissionDenied" }
316
+ var failure: String?
317
+ queue.sync {
318
+ guard !disposed else { failure = "unavailable"; return }
319
+ let cameraId = options["cameraId"] as? String
320
+ let photo = options["photo"] as? Bool ?? false
321
+ let audio = !photo && (options["audio"] as? Bool ?? false)
322
+ rotationAngle = (options["rotationAngle"] as? NSNumber)?.doubleValue
323
+ let devices = cameraDevices()
324
+ guard let device = cameraId != nil
325
+ ? devices.first(where: { $0.uniqueID == cameraId })
326
+ : devices.first ?? AVCaptureDevice.default(for: .video)
327
+ else {
328
+ failure = "unavailable"
329
+ return
330
+ }
331
+
332
+ guard CameraOwnership.claim(device.uniqueID, owner: self) else {
333
+ failure = "busy"
334
+ return
335
+ }
336
+ claimedCameraIds.insert(device.uniqueID)
337
+ defer {
338
+ // Failed reconfiguration may retain either input. Hold both claims
339
+ // until disposal rather than admitting an overlapping session.
340
+ if failure == nil {
341
+ for old in claimedCameraIds where old != device.uniqueID {
342
+ CameraOwnership.release(old, owner: self)
343
+ }
344
+ claimedCameraIds = [device.uniqueID]
345
+ }
346
+ }
347
+ session.beginConfiguration()
348
+ if videoInput == nil || videoInput?.device.uniqueID != device.uniqueID {
349
+ do {
350
+ let input = try AVCaptureDeviceInput(device: device)
351
+ if videoInput != nil {
352
+ session.removeInput(videoInput!)
353
+ }
354
+ videoInput = input
355
+ if session.canAddInput(input) {
356
+ session.addInput(input)
357
+ } else {
358
+ failure = "unavailable"
359
+ }
360
+ } catch {
361
+ session.commitConfiguration()
362
+ failure = "unavailable"
363
+ return
364
+ }
365
+ }
366
+
367
+ if audio && audioInput == nil {
368
+ if let microphone = AVCaptureDevice.default(for: .audio),
369
+ let input = try? AVCaptureDeviceInput(device: microphone),
370
+ session.canAddInput(input)
371
+ {
372
+ audioInput = input
373
+ session.addInput(input)
374
+ }
375
+ } else if !audio, let audioInput {
376
+ session.removeInput(audioInput)
377
+ self.audioInput = nil
378
+ }
379
+
380
+ if photo && photoOutput == nil {
381
+ let output = AVCapturePhotoOutput()
382
+ if session.canAddOutput(output) {
383
+ session.addOutput(output)
384
+ photoOutput = output
385
+ } else {
386
+ failure = "unavailable"
387
+ }
388
+ }
389
+ if !photo && movieOutput == nil {
390
+ let output = AVCaptureMovieFileOutput()
391
+ if session.canAddOutput(output) {
392
+ session.addOutput(output)
393
+ movieOutput = output
394
+ }
395
+ }
396
+ session.commitConfiguration()
397
+ if let rotationAngle {
398
+ let connection = movieOutput?.connection(with: .video)
399
+ let supported: Bool
400
+ if #available(macOS 14.0, *) {
401
+ supported = connection?.isVideoRotationAngleSupported(rotationAngle) ?? false
402
+ } else {
403
+ supported = false
404
+ }
405
+ if !supported {
406
+ failure = "unsupportedConfiguration"
407
+ return
408
+ }
409
+ }
410
+ configuredCameraId = device.uniqueID
411
+ configuredAudio = audio
412
+ applyRotation(movieOutput?.connection(with: .video))
413
+ applyRotation(previewLayer?.connection)
414
+ }
415
+ return failure.map { $0 as NSString }
416
+ }
417
+
418
+ @objc public func activeDeviceId() -> NSString? {
419
+ videoInput?.device.uniqueID as NSString?
420
+ }
421
+
422
+ @objc public func audioInputAttached() -> Bool {
423
+ audioInput != nil
424
+ }
425
+
426
+ @objc public func recordedMs() -> Double {
427
+ guard let duration = movieOutput?.recordedDuration, duration.isValid else {
428
+ return 0
429
+ }
430
+ return CMTimeGetSeconds(duration) * 1000
431
+ }
432
+
433
+ @objc public func attachPreview(_ view: NSView) -> NSString? {
434
+ guard let session = ensureSession() else { return "permissionDenied" }
435
+ previewView = view
436
+ previewLayer = AVCaptureVideoPreviewLayer(session: session)
437
+ previewLayer?.videoGravity = .resizeAspectFill
438
+ view.wantsLayer = true
439
+ if let layer = previewLayer {
440
+ layer.frame = view.bounds
441
+ layer.autoresizingMask = [.layerWidthSizable, .layerHeightSizable]
442
+ view.layer?.addSublayer(layer)
443
+ applyRotation(layer.connection)
444
+ }
445
+ queue.async {
446
+ if !self.disposed && !session.isRunning {
447
+ session.startRunning()
448
+ }
449
+ }
450
+ return nil
451
+ }
452
+
453
+ /// Stills use AVCapturePhotoOutput, never movie recording or an audio input.
454
+ @objc public func capturePhoto(_ reply: @escaping (NSData?) -> Void) -> NSString? {
455
+ guard !disposed, photoDelegate == nil, let photoOutput, session?.isRunning == true else {
456
+ return "unavailable"
457
+ }
458
+ guard photoOutput.availablePhotoCodecTypes.contains(.jpeg) else { return "unavailable" }
459
+ let delegate = XplatPhotoDelegate()
460
+ delegate.reply = { [weak self] data in
461
+ self?.photoDelegate = nil
462
+ reply(data as NSData?)
463
+ }
464
+ photoDelegate = delegate
465
+ let settings = AVCapturePhotoSettings(format: [AVVideoCodecKey: AVVideoCodecType.jpeg])
466
+ queue.async { [self] in
467
+ guard !disposed else { return }
468
+ photoOutput.capturePhoto(with: settings, delegate: delegate)
469
+ }
470
+ return nil
471
+ }
472
+
473
+ /// Meters one autofocus/auto-exposure pass at a normalized preview
474
+ /// point (0-1, top-left origin as the user sees it). Returns whether
475
+ /// any metering dimension was applied.
476
+ @objc public func focusAtPoint(_ options: NSDictionary) -> Bool {
477
+ guard !disposed, let layer = previewLayer else { return false }
478
+ let x = (options["x"] as? NSNumber)?.doubleValue ?? 0
479
+ let y = (options["y"] as? NSNumber)?.doubleValue ?? 0
480
+ var point = CGPoint(
481
+ x: min(max(x, 0), 1) * layer.bounds.width,
482
+ y: min(max(y, 0), 1) * layer.bounds.height
483
+ )
484
+ // Unflipped AppKit views are bottom-left origin; the preview
485
+ // layer's conversion expects the layer's top-left space.
486
+ if let view = previewView, !view.isFlipped {
487
+ point.y = layer.bounds.height - point.y
488
+ }
489
+ let devicePoint = layer.captureDevicePointConverted(fromLayerPoint: point)
490
+ var applied = false
491
+ queue.sync {
492
+ guard !disposed, let device = videoInput?.device else { return }
493
+ let focus = device.isFocusPointOfInterestSupported && device.isFocusModeSupported(.autoFocus)
494
+ let expose = device.isExposurePointOfInterestSupported && device.isExposureModeSupported(.autoExpose)
495
+ guard focus || expose else { return }
496
+ do {
497
+ try device.lockForConfiguration()
498
+ // Single-shot AF/AE at the point — the AVCam tap-to-focus
499
+ // convention; the device locks after metering.
500
+ if focus {
501
+ device.focusPointOfInterest = devicePoint
502
+ device.focusMode = .autoFocus
503
+ }
504
+ if expose {
505
+ device.exposurePointOfInterest = devicePoint
506
+ device.exposureMode = .autoExpose
507
+ }
508
+ device.unlockForConfiguration()
509
+ applied = true
510
+ } catch {}
511
+ }
512
+ return applied
513
+ }
514
+
515
+ @objc public func detachPreview() {
516
+ previewLayer?.removeFromSuperlayer()
517
+ previewLayer = nil
518
+ previewView = nil
519
+ queue.async { [weak self] in
520
+ self?.session?.stopRunning()
521
+ }
522
+ }
523
+
524
+ /** App-private recordings root: ~/Library/Application Support/<bundle>/octane-camera */
525
+ @objc public func movieDirectory() -> NSString? {
526
+ guard let base = FileManager.default.urls(
527
+ for: .applicationSupportDirectory,
528
+ in: .userDomainMask
529
+ ).first else {
530
+ return nil
531
+ }
532
+ let identifier = Bundle.main.bundleIdentifier ?? "org.octane.xplat"
533
+ let directory = base.appendingPathComponent(identifier).appendingPathComponent("octane-camera")
534
+ guard (try? FileManager.default.createDirectory(
535
+ at: directory,
536
+ withIntermediateDirectories: true
537
+ )) != nil else {
538
+ return nil
539
+ }
540
+ return directory.absoluteString as NSString
541
+ }
542
+
543
+ @objc public func startRecording(_ path: String, maximumDurationMs: Double) -> NSString? {
544
+ guard session != nil, let movieOutput else { return "unavailable" }
545
+ queue.sync {
546
+ let url = URL(fileURLWithPath: path)
547
+ recordingPath = path
548
+ movieOutput.maxRecordedDuration =
549
+ maximumDurationMs > 0
550
+ ? CMTime(seconds: maximumDurationMs / 1000.0, preferredTimescale: 1000)
551
+ : .invalid
552
+ recordingDelegate.onStart = { [weak self] in
553
+ self?.send([
554
+ "type": "recordingStarted",
555
+ "fileUrl": url.absoluteString,
556
+ "audio": self?.audioInput != nil,
557
+ ])
558
+ }
559
+ recordingDelegate.onFinish = { [weak self] fileUrl, succeeded, limitReached, failure in
560
+ var payload: [String: Any] = [
561
+ "type": "recordingFinished",
562
+ "fileUrl": fileUrl.absoluteString,
563
+ "succeeded": succeeded,
564
+ "limitReached": limitReached,
565
+ ]
566
+ if succeeded, FileManager.default.fileExists(atPath: fileUrl.path) {
567
+ payload["clip"] = movieClipInfo(fileUrl)
568
+ } else {
569
+ // Reclaim only the file this attempt created — admission
570
+ // never allows a pre-existing destination.
571
+ try? FileManager.default.removeItem(at: fileUrl)
572
+ payload["error"] = [
573
+ "domain": failure?.domain ?? "XplatCamera",
574
+ "code": failure?.code ?? -1,
575
+ "message": failure?.localizedDescription ?? "Movie recording did not complete",
576
+ ]
577
+ }
578
+ self?.send(payload)
579
+ }
580
+ movieOutput.startRecording(to: url, recordingDelegate: recordingDelegate)
581
+ }
582
+ return nil
583
+ }
584
+
585
+ @objc public func stopRecording() {
586
+ movieOutput?.stopRecording()
587
+ }
588
+
589
+ @objc public func recording() -> Bool {
590
+ movieOutput?.isRecording ?? false
591
+ }
592
+
593
+ private let recordingDelegate = XplatRecordingDelegate()
594
+
595
+ /** JSON {width,height} of the configured device's active format. */
596
+ @objc public func previewSize() -> String {
597
+ guard let device = videoInput?.device else {
598
+ return "{}"
599
+ }
600
+ let dimensions = CMVideoFormatDescriptionGetDimensions(
601
+ device.activeFormat.formatDescription
602
+ )
603
+ return "{\"width\":\(Int(dimensions.width)),\"height\":\(Int(dimensions.height))}"
604
+ }
605
+
606
+ @objc public func dispose() {
607
+ guard !disposed else { return }
608
+ disposed = true
609
+ for observer in observers { NotificationCenter.default.removeObserver(observer) }
610
+ observers = []
611
+ previewLayer?.removeFromSuperlayer()
612
+ previewLayer = nil
613
+ previewView = nil
614
+ photoDelegate?.reply = nil
615
+ photoDelegate = nil
616
+ // Keep the session alive until stop completes. Releasing the claim sooner
617
+ // could admit another host while AVFoundation still holds the hardware.
618
+ queue.async { [self] in
619
+ session?.stopRunning()
620
+ for id in claimedCameraIds { CameraOwnership.release(id, owner: self) }
621
+ claimedCameraIds = []
622
+ session = nil
623
+ videoInput = nil
624
+ audioInput = nil
625
+ movieOutput = nil
626
+ photoOutput = nil
627
+ }
628
+ emit = nil
629
+ }
630
+
631
+ deinit {
632
+ for observer in observers { NotificationCenter.default.removeObserver(observer) }
633
+ let stoppedSession = session
634
+ // Explicit disposal handles claims. A live claim keeps no strong owner;
635
+ // synchronous fallback only runs if consumers omit disposal.
636
+ stoppedSession?.stopRunning()
637
+ for id in claimedCameraIds { CameraOwnership.release(id, owner: self) }
638
+ }
639
+ }