@octane-xplat/camera 0.12.0 → 0.14.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.
Files changed (83) hide show
  1. package/README.md +63 -3
  2. package/dist/linux/CameraView.web.tsrx.js +337 -0
  3. package/dist/linux/host-movie.web.js +100 -0
  4. package/dist/linux/index.js +5 -0
  5. package/dist/linux/layout-child.web.js +29 -0
  6. package/dist/linux/movie-metadata.web.js +65 -0
  7. package/dist/linux/octane-client-build.json +8 -0
  8. package/dist/linux/session-backend.linux.js +479 -0
  9. package/dist/linux/session-core.js +368 -0
  10. package/dist/linux/session.js +29 -0
  11. package/dist/linux/synthetic-session.js +280 -0
  12. package/dist/linux/types.js +19 -0
  13. package/dist/native/CameraView.android.js +25 -1
  14. package/dist/native/CameraView.ios.js +24 -1
  15. package/dist/native/CameraView.windows.js +173 -0
  16. package/dist/native/camera-permission.windows.js +43 -0
  17. package/dist/native/camera-session.windows.js +78 -0
  18. package/dist/native/cx.mobile.js +3 -2
  19. package/dist/native/cx.windows.js +9 -0
  20. package/dist/native/index.js +4 -1
  21. package/dist/native/layout-child.windows.js +53 -0
  22. package/dist/native/octane-client-build.json +1 -1
  23. package/dist/native/session-backend.android.js +234 -0
  24. package/dist/native/session-backend.ios.js +682 -0
  25. package/dist/native/session-backend.js +55 -0
  26. package/dist/native/session-backend.windows.js +905 -0
  27. package/dist/native/session-core.js +368 -0
  28. package/dist/native/session.js +29 -0
  29. package/dist/native/synthetic-session.js +280 -0
  30. package/dist/native/types.js +19 -0
  31. package/dist/web/CameraView.web.tsrx.js +28 -8
  32. package/dist/web/host-movie.web.js +100 -0
  33. package/dist/web/index.js +4 -1
  34. package/dist/web/movie-metadata.web.js +65 -0
  35. package/dist/web/movie-storage.web.js +119 -0
  36. package/dist/web/octane-client-build.json +1 -1
  37. package/dist/web/session-backend.web.js +490 -0
  38. package/dist/web/session-core.js +368 -0
  39. package/dist/web/session.js +29 -0
  40. package/dist/web/synthetic-session.js +280 -0
  41. package/dist/web/types.js +19 -0
  42. package/package.json +23 -5
  43. package/platforms/android/AndroidManifest.xml +2 -0
  44. package/platforms/android/include.gradle +1 -0
  45. package/platforms/android/java/com/octanexplat/camera/CameraPreviewView.java +4 -0
  46. package/platforms/android/java/com/octanexplat/camera/CameraSessionBridge.java +363 -0
  47. package/platforms/ios/Info.plist +2 -0
  48. package/platforms/macos/src/XplatCameraHost.swift +513 -0
  49. package/src/CameraView.android.tsrx +21 -1
  50. package/src/CameraView.ios.tsrx +28 -2
  51. package/src/CameraView.macos.tsrx +93 -0
  52. package/src/CameraView.web.tsrx +20 -2
  53. package/src/CameraView.windows.tsrx +124 -0
  54. package/src/camera-permission.windows.ts +76 -0
  55. package/src/camera-session.macos.ts +75 -0
  56. package/src/camera-session.windows.ts +123 -0
  57. package/src/cx.macos.ts +5 -0
  58. package/src/cx.mobile.ts +3 -2
  59. package/src/cx.windows.ts +6 -0
  60. package/src/host-movie.web.ts +180 -0
  61. package/src/index.ts +31 -0
  62. package/src/index.web.ts +31 -0
  63. package/src/layout-child.windows.ts +79 -0
  64. package/src/movie-metadata.web.ts +84 -0
  65. package/src/movie-storage.web.ts +164 -0
  66. package/src/props.ts +7 -1
  67. package/src/session-backend.android.ts +397 -0
  68. package/src/session-backend.ios.ts +1092 -0
  69. package/src/session-backend.linux.ts +869 -0
  70. package/src/session-backend.macos.ts +624 -0
  71. package/src/session-backend.ts +57 -0
  72. package/src/session-backend.web.ts +874 -0
  73. package/src/session-backend.windows.ts +1517 -0
  74. package/src/session-core.ts +659 -0
  75. package/src/session.ts +37 -0
  76. package/src/synthetic-session.ts +367 -0
  77. package/src/types.ts +329 -0
  78. package/types/index.d.ts +43 -0
  79. package/types/props.d.ts +7 -1
  80. package/types/session-core.d.ts +129 -0
  81. package/types/synthetic-session.d.ts +48 -0
  82. package/types/types.d.ts +263 -0
  83. package/src/CameraView.windows.ts +0 -5
@@ -0,0 +1,513 @@
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
+ @objc(XplatCameraHost)
94
+ public final class XplatCameraHost: NSObject {
95
+ private var session: AVCaptureSession?
96
+ private var videoInput: AVCaptureDeviceInput?
97
+ private var audioInput: AVCaptureDeviceInput?
98
+ private var movieOutput: AVCaptureMovieFileOutput?
99
+ private var previewLayer: AVCaptureVideoPreviewLayer?
100
+ private weak var previewView: NSView?
101
+ private var configuredCameraId: String?
102
+ private var configuredAudio = false
103
+ private var rotationAngle: Double?
104
+ private var emit: ((String) -> Void)?
105
+ private var observers: [NSObjectProtocol] = []
106
+ private var disposed = false
107
+ private var recordingPath: String?
108
+ private let queue = DispatchQueue(label: "org.octane.xplat.camera")
109
+
110
+ public override init() {
111
+ super.init()
112
+ }
113
+
114
+ private func send(_ payload: [String: Any]) {
115
+ guard
116
+ let emit,
117
+ let data = try? JSONSerialization.data(withJSONObject: payload),
118
+ let text = String(data: data, encoding: .utf8)
119
+ else {
120
+ return
121
+ }
122
+
123
+ emit(text)
124
+ }
125
+
126
+ @objc public func installObserver(_ listener: @escaping (String) -> Void) {
127
+ emit = listener
128
+ }
129
+
130
+ private func status(for mediaType: AVMediaType) -> String {
131
+ switch AVCaptureDevice.authorizationStatus(for: mediaType) {
132
+ case .authorized:
133
+ return "granted"
134
+ case .denied:
135
+ return "denied"
136
+ case .restricted:
137
+ return "restricted"
138
+ case .notDetermined:
139
+ return "notDetermined"
140
+ @unknown default:
141
+ return "unknown"
142
+ }
143
+ }
144
+
145
+ private func mediaKind(_ kind: String) -> AVMediaType {
146
+ kind == "microphone" ? .audio : .video
147
+ }
148
+
149
+ private func declared(_ mediaType: AVMediaType) -> Bool {
150
+ let key = mediaType == .audio ? "NSMicrophoneUsageDescription" : "NSCameraUsageDescription"
151
+ return Bundle.main.object(forInfoDictionaryKey: key) != nil
152
+ }
153
+
154
+ @objc public func permissionStatus(_ kind: String) -> NSString {
155
+ let mediaType = mediaKind(kind)
156
+ guard declared(mediaType) else { return "undeclared" }
157
+ return status(for: mediaType) as NSString
158
+ }
159
+
160
+ @objc public func requestPermission(_ kind: String, reply: @escaping (String) -> Void) {
161
+ let mediaType = mediaKind(kind)
162
+ guard declared(mediaType) else {
163
+ reply("undeclared")
164
+ return
165
+ }
166
+ let current = status(for: mediaType)
167
+ guard current == "notDetermined" else {
168
+ reply(current)
169
+ return
170
+ }
171
+ AVCaptureDevice.requestAccess(for: mediaType) { _ in
172
+ DispatchQueue.main.async { reply(self.status(for: mediaType)) }
173
+ }
174
+ }
175
+
176
+ private func cameraDevices() -> [AVCaptureDevice] {
177
+ let types: [AVCaptureDevice.DeviceType] = [.builtInWideAngleCamera, .externalUnknown]
178
+ var found = AVCaptureDevice.DiscoverySession(
179
+ deviceTypes: types,
180
+ mediaType: .video,
181
+ position: .unspecified
182
+ ).devices
183
+ // The discovery session is authoritative; keep the legacy enumerator as
184
+ // a merge source for devices on older system builds.
185
+ for device in AVCaptureDevice.devices(for: .video) where !found.contains(device) {
186
+ found.append(device)
187
+ }
188
+ return found
189
+ }
190
+
191
+ @objc public func devices() -> NSString {
192
+ let list: [[String: Any]] = cameraDevices().map {
193
+ ["id": $0.uniqueID, "name": $0.localizedName, "facing": NSNull()]
194
+ }
195
+ guard
196
+ let data = try? JSONSerialization.data(withJSONObject: list),
197
+ let text = String(data: data, encoding: .utf8)
198
+ else {
199
+ return "[]"
200
+ }
201
+ return text as NSString
202
+ }
203
+
204
+ @objc public func audioDeviceAvailable() -> Bool {
205
+ !AVCaptureDevice.devices(for: .audio).isEmpty
206
+ }
207
+
208
+ private func ensureSession() -> AVCaptureSession? {
209
+ if let session {
210
+ return session
211
+ }
212
+ guard status(for: .video) == "granted" else {
213
+ return nil
214
+ }
215
+ let session = AVCaptureSession()
216
+ if session.canSetSessionPreset(.hd1280x720) {
217
+ session.sessionPreset = .hd1280x720
218
+ }
219
+ self.session = session
220
+ let names: [Notification.Name] = [
221
+ .AVCaptureSessionWasInterrupted,
222
+ .AVCaptureSessionInterruptionEnded,
223
+ .AVCaptureSessionRuntimeError,
224
+ .AVCaptureSessionDidStartRunning,
225
+ .AVCaptureSessionDidStopRunning,
226
+ ]
227
+ for name in names {
228
+ observers.append(
229
+ NotificationCenter.default.addObserver(
230
+ forName: name,
231
+ object: session,
232
+ queue: .main
233
+ ) { [weak self] note in
234
+ self?.handleSessionNotification(note)
235
+ }
236
+ )
237
+ }
238
+ for (name, available) in [
239
+ (NSNotification.Name.AVCaptureDeviceWasDisconnected, false),
240
+ (NSNotification.Name.AVCaptureDeviceWasConnected, true),
241
+ ] {
242
+ observers.append(
243
+ NotificationCenter.default.addObserver(
244
+ forName: name,
245
+ object: nil,
246
+ queue: .main
247
+ ) { [weak self] _ in
248
+ self?.send(["type": "availability", "available": available])
249
+ }
250
+ )
251
+ }
252
+ return session
253
+ }
254
+
255
+ private func handleSessionNotification(_ note: Notification) {
256
+ switch note.name {
257
+ case .AVCaptureSessionWasInterrupted:
258
+ // macOS publishes no interruption-reason keys — the cause stays
259
+ // honest 'unknown' rather than a guessed code.
260
+ send(["type": "interrupted", "cause": "unknown"])
261
+ case .AVCaptureSessionInterruptionEnded:
262
+ send(["type": "interruptionEnded"])
263
+ case .AVCaptureSessionRuntimeError:
264
+ send(["type": "availability", "available": false])
265
+ send(["type": "interrupted", "cause": "cameraInUse"])
266
+ case .AVCaptureSessionDidStartRunning:
267
+ send(["type": "previewReady", "ready": true])
268
+ case .AVCaptureSessionDidStopRunning:
269
+ send(["type": "previewReady", "ready": false])
270
+ default:
271
+ break
272
+ }
273
+ }
274
+
275
+ /** Rotation-angle connections exist on macOS 14+; earlier hosts cannot
276
+ * lock a take's orientation. */
277
+ @objc public func supportsRotationAngles() -> Bool {
278
+ if #available(macOS 14.0, *) {
279
+ return true
280
+ }
281
+ return false
282
+ }
283
+
284
+ private func applyRotation(_ connection: AVCaptureConnection?) {
285
+ guard let connection, let rotationAngle else { return }
286
+ if #available(macOS 14.0, *),
287
+ connection.isVideoRotationAngleSupported(rotationAngle)
288
+ {
289
+ connection.videoRotationAngle = rotationAngle
290
+ }
291
+ }
292
+
293
+ @objc public func configure(_ options: NSDictionary) -> NSString? {
294
+ guard let session = ensureSession() else { return "permissionDenied" }
295
+ var failure: String?
296
+ queue.sync {
297
+ let cameraId = options["cameraId"] as? String
298
+ let audio = options["audio"] as? Bool ?? false
299
+ rotationAngle = (options["rotationAngle"] as? NSNumber)?.doubleValue
300
+ let devices = cameraDevices()
301
+ guard let device = cameraId != nil
302
+ ? devices.first(where: { $0.uniqueID == cameraId })
303
+ : devices.first ?? AVCaptureDevice.default(for: .video)
304
+ else {
305
+ failure = "unavailable"
306
+ return
307
+ }
308
+
309
+ session.beginConfiguration()
310
+ if videoInput == nil || videoInput?.device.uniqueID != device.uniqueID {
311
+ do {
312
+ let input = try AVCaptureDeviceInput(device: device)
313
+ if videoInput != nil {
314
+ session.removeInput(videoInput!)
315
+ }
316
+ videoInput = input
317
+ if session.canAddInput(input) {
318
+ session.addInput(input)
319
+ }
320
+ } catch {
321
+ session.commitConfiguration()
322
+ failure = "unavailable"
323
+ return
324
+ }
325
+ }
326
+
327
+ if audio && audioInput == nil {
328
+ if let microphone = AVCaptureDevice.default(for: .audio),
329
+ let input = try? AVCaptureDeviceInput(device: microphone),
330
+ session.canAddInput(input)
331
+ {
332
+ audioInput = input
333
+ session.addInput(input)
334
+ }
335
+ } else if !audio, let audioInput {
336
+ session.removeInput(audioInput)
337
+ self.audioInput = nil
338
+ }
339
+
340
+ if movieOutput == nil {
341
+ let output = AVCaptureMovieFileOutput()
342
+ if session.canAddOutput(output) {
343
+ session.addOutput(output)
344
+ movieOutput = output
345
+ }
346
+ }
347
+ session.commitConfiguration()
348
+ if let rotationAngle {
349
+ let connection = movieOutput?.connection(with: .video)
350
+ let supported: Bool
351
+ if #available(macOS 14.0, *) {
352
+ supported = connection?.isVideoRotationAngleSupported(rotationAngle) ?? false
353
+ } else {
354
+ supported = false
355
+ }
356
+ if !supported {
357
+ failure = "unsupportedConfiguration"
358
+ return
359
+ }
360
+ }
361
+ configuredCameraId = device.uniqueID
362
+ configuredAudio = audio
363
+ applyRotation(movieOutput?.connection(with: .video))
364
+ applyRotation(previewLayer?.connection)
365
+ }
366
+ return failure.map { $0 as NSString }
367
+ }
368
+
369
+ @objc public func activeDeviceId() -> NSString? {
370
+ videoInput?.device.uniqueID as NSString?
371
+ }
372
+
373
+ @objc public func audioInputAttached() -> Bool {
374
+ audioInput != nil
375
+ }
376
+
377
+ @objc public func recordedMs() -> Double {
378
+ guard let duration = movieOutput?.recordedDuration, duration.isValid else {
379
+ return 0
380
+ }
381
+ return CMTimeGetSeconds(duration) * 1000
382
+ }
383
+
384
+ @objc public func attachPreview(_ view: NSView) -> NSString? {
385
+ guard let session = ensureSession() else { return "permissionDenied" }
386
+ previewView = view
387
+ previewLayer = AVCaptureVideoPreviewLayer(session: session)
388
+ previewLayer?.videoGravity = .resizeAspectFill
389
+ view.wantsLayer = true
390
+ if let layer = previewLayer {
391
+ layer.frame = view.bounds
392
+ layer.autoresizingMask = [.layerWidthSizable, .layerHeightSizable]
393
+ view.layer?.addSublayer(layer)
394
+ applyRotation(layer.connection)
395
+ }
396
+ queue.async {
397
+ if !session.isRunning {
398
+ session.startRunning()
399
+ }
400
+ }
401
+ return nil
402
+ }
403
+
404
+ @objc public func detachPreview() {
405
+ previewLayer?.removeFromSuperlayer()
406
+ previewLayer = nil
407
+ previewView = nil
408
+ queue.async { [weak self] in
409
+ self?.session?.stopRunning()
410
+ }
411
+ }
412
+
413
+ /** App-private recordings root: ~/Library/Application Support/<bundle>/octane-camera */
414
+ @objc public func movieDirectory() -> NSString? {
415
+ guard let base = FileManager.default.urls(
416
+ for: .applicationSupportDirectory,
417
+ in: .userDomainMask
418
+ ).first else {
419
+ return nil
420
+ }
421
+ let identifier = Bundle.main.bundleIdentifier ?? "org.octane.xplat"
422
+ let directory = base.appendingPathComponent(identifier).appendingPathComponent("octane-camera")
423
+ guard (try? FileManager.default.createDirectory(
424
+ at: directory,
425
+ withIntermediateDirectories: true
426
+ )) != nil else {
427
+ return nil
428
+ }
429
+ return directory.absoluteString as NSString
430
+ }
431
+
432
+ @objc public func startRecording(_ path: String, maximumDurationMs: Double) -> NSString? {
433
+ guard session != nil, let movieOutput else { return "unavailable" }
434
+ queue.sync {
435
+ let url = URL(fileURLWithPath: path)
436
+ recordingPath = path
437
+ movieOutput.maxRecordedDuration =
438
+ maximumDurationMs > 0
439
+ ? CMTime(seconds: maximumDurationMs / 1000.0, preferredTimescale: 1000)
440
+ : .invalid
441
+ recordingDelegate.onStart = { [weak self] in
442
+ self?.send([
443
+ "type": "recordingStarted",
444
+ "fileUrl": url.absoluteString,
445
+ "audio": self?.audioInput != nil,
446
+ ])
447
+ }
448
+ recordingDelegate.onFinish = { [weak self] fileUrl, succeeded, limitReached, failure in
449
+ var payload: [String: Any] = [
450
+ "type": "recordingFinished",
451
+ "fileUrl": fileUrl.absoluteString,
452
+ "succeeded": succeeded,
453
+ "limitReached": limitReached,
454
+ ]
455
+ if succeeded, FileManager.default.fileExists(atPath: fileUrl.path) {
456
+ payload["clip"] = movieClipInfo(fileUrl)
457
+ } else {
458
+ // Reclaim only the file this attempt created — admission
459
+ // never allows a pre-existing destination.
460
+ try? FileManager.default.removeItem(at: fileUrl)
461
+ payload["error"] = [
462
+ "domain": failure?.domain ?? "XplatCamera",
463
+ "code": failure?.code ?? -1,
464
+ "message": failure?.localizedDescription ?? "Movie recording did not complete",
465
+ ]
466
+ }
467
+ self?.send(payload)
468
+ }
469
+ movieOutput.startRecording(to: url, recordingDelegate: recordingDelegate)
470
+ }
471
+ return nil
472
+ }
473
+
474
+ @objc public func stopRecording() {
475
+ movieOutput?.stopRecording()
476
+ }
477
+
478
+ @objc public func recording() -> Bool {
479
+ movieOutput?.isRecording ?? false
480
+ }
481
+
482
+ private let recordingDelegate = XplatRecordingDelegate()
483
+
484
+ /** JSON {width,height} of the configured device's active format. */
485
+ @objc public func previewSize() -> String {
486
+ guard let device = videoInput?.device else {
487
+ return "{}"
488
+ }
489
+ let dimensions = CMVideoFormatDescriptionGetDimensions(
490
+ device.activeFormat.formatDescription
491
+ )
492
+ return "{\"width\":\(Int(dimensions.width)),\"height\":\(Int(dimensions.height))}"
493
+ }
494
+
495
+ @objc public func dispose() {
496
+ guard !disposed else { return }
497
+ disposed = true
498
+ for observer in observers {
499
+ NotificationCenter.default.removeObserver(observer)
500
+ }
501
+ observers = []
502
+ detachPreview()
503
+ session = nil
504
+ videoInput = nil
505
+ audioInput = nil
506
+ movieOutput = nil
507
+ emit = nil
508
+ }
509
+
510
+ deinit {
511
+ dispose()
512
+ }
513
+ }
@@ -4,6 +4,7 @@ import { Application, View } from '@nativescript/core'
4
4
  import { registerElement } from '@nativescript-community/octane'
5
5
  import type { Attributes } from '@nativescript-community/octane/intrinsics'
6
6
  import type { CameraViewProps } from './props'
7
+ import { attachSessionPreview } from './session'
7
8
  import { cameraHardwarePresent, ensureCameraPermission } from './camera-permission.mobile'
8
9
  import { applyEscapeProps, layoutChildProps, nativeAccessibilityRole, nativeAccessibilityState } from './layout-child'
9
10
  import { cx } from './cx.mobile'
@@ -44,6 +45,25 @@ export function CameraView(props: CameraViewProps) @{
44
45
  if (!active) return () => nativeView.setCameraActive(false)
45
46
 
46
47
  const fail = (message: string) => props.onError?.({ message })
48
+ if (props.session) {
49
+ if (props.facing !== undefined) {
50
+ fail('camera session conflicts with facing')
51
+ return () => nativeView.setCameraActive(false)
52
+ }
53
+
54
+ try {
55
+ const detach = attachSessionPreview(props.session, nativeView, {
56
+ onReady: props.onReady,
57
+ onError: (issue) => fail(issue.message),
58
+ })
59
+
60
+ return () => { detach(); nativeView.setCameraActive(false) }
61
+ } catch (error: any) {
62
+ fail(error?.message ?? 'camera session error')
63
+ return () => nativeView.setCameraActive(false)
64
+ }
65
+ }
66
+
47
67
  if (!cameraHardwarePresent()) {
48
68
  fail('camera unsupported')
49
69
  return () => nativeView.setCameraActive(false)
@@ -98,7 +118,7 @@ export function CameraView(props: CameraViewProps) @{
98
118
  } catch {}
99
119
  }
100
120
  }
101
- }, [active, facing])
121
+ }, [active, facing, props.session])
102
122
 
103
123
  useEffect(() => {
104
124
  const view = host.current
@@ -3,10 +3,13 @@ import { useImperativeHandle, useCallback, useEffect, useRef } from 'octane'
3
3
  import type { CameraViewProps } from './props'
4
4
  import { cameraHardwarePresent, ensureCameraPermission } from './camera-permission.mobile'
5
5
  import { startCameraPreview } from './camera-session.ios'
6
+ import { attachSessionPreview } from './session'
6
7
  import { applyEscapeProps, layoutChildProps, nativeAccessibilityRole, nativeAccessibilityState } from './layout-child'
7
8
  import { cx } from './cx.mobile'
8
9
 
9
- /** Live iOS preview backed by AVFoundation. */
10
+ /** Live iOS preview backed by AVFoundation. With `session`, the preview
11
+ * attaches to the session-owned AVCaptureSession so movie recording can
12
+ * share it; without one the view owns its session as before. */
10
13
  export function CameraView(props: CameraViewProps) @{
11
14
  const host = useRef<any>(null)
12
15
  const active = props.active !== false
@@ -24,6 +27,29 @@ export function CameraView(props: CameraViewProps) @{
24
27
  let stopPreview: (() => void) | undefined
25
28
  const fail = (message: string) => props.onError?.({ message })
26
29
 
30
+ if (props.session) {
31
+ if (props.facing !== undefined) {
32
+ fail('camera session conflicts with facing')
33
+ return
34
+ }
35
+
36
+ try {
37
+ const detach = attachSessionPreview(props.session, view, {
38
+ onReady: () => {
39
+ if (!disposed) props.onReady?.()
40
+ },
41
+ })
42
+
43
+ return () => {
44
+ disposed = true
45
+ detach()
46
+ }
47
+ } catch (error: any) {
48
+ fail(error?.message ?? 'camera session error')
49
+ return
50
+ }
51
+ }
52
+
27
53
  if (!cameraHardwarePresent()) {
28
54
  fail('camera unsupported')
29
55
  return
@@ -47,7 +73,7 @@ export function CameraView(props: CameraViewProps) @{
47
73
  disposed = true
48
74
  stopPreview?.()
49
75
  }
50
- }, [active, facing])
76
+ }, [active, facing, props.session])
51
77
 
52
78
  useEffect(() => {
53
79
  const view = host.current
@@ -0,0 +1,93 @@
1
+ /** @jsxImportSource @octane-xplat/macos-renderer */
2
+ import { useImperativeHandle, useCallback, useEffect, useRef } from 'octane'
3
+ import type { CameraViewProps } from './props'
4
+ import { cameraHardwarePresent, ensureCameraPermission, startCameraPreview } from './camera-session.macos'
5
+ import { attachSessionPreview } from './session'
6
+ import { layoutChildProps, nativeAccessibilityRole, nativeAccessibilityState } from './layout-child'
7
+ import { cx } from './cx.macos'
8
+
9
+ /** Live AppKit preview backed by AVFoundation. With `session`, the preview
10
+ * attaches to the session-owned AVCaptureSession so movie recording can
11
+ * share it; without one the view owns its session as before. */
12
+ export function CameraView(props: CameraViewProps) @{
13
+ const host = useRef<any>(null)
14
+ const active = props.active !== false
15
+ const facing = props.facing ?? 'back'
16
+ useImperativeHandle(props.ref, () => ({ native: host.current }), [])
17
+
18
+ const bindHost = useCallback((view: any) => {
19
+ host.current = view
20
+ }, [])
21
+
22
+ useEffect(() => {
23
+ const view = host.current
24
+ if (!view || !active) return
25
+ let disposed = false
26
+ let stopPreview: (() => void) | undefined
27
+ const fail = (message: string) => props.onError?.({ message })
28
+
29
+ if (props.session) {
30
+ if (props.facing !== undefined) {
31
+ fail('camera session conflicts with facing')
32
+ return
33
+ }
34
+
35
+ try {
36
+ const detach = attachSessionPreview(props.session, view, {
37
+ onReady: () => {
38
+ if (!disposed) props.onReady?.()
39
+ },
40
+ })
41
+
42
+ return () => {
43
+ disposed = true
44
+ detach()
45
+ }
46
+ } catch (error: any) {
47
+ fail(error?.message ?? 'camera session error')
48
+ return
49
+ }
50
+ }
51
+
52
+ if (!cameraHardwarePresent()) {
53
+ fail('camera unsupported')
54
+ return
55
+ }
56
+
57
+ ensureCameraPermission().then((granted) => {
58
+ if (disposed) return
59
+ if (!granted) {
60
+ fail('camera permission denied')
61
+ return
62
+ }
63
+
64
+ try {
65
+ stopPreview = startCameraPreview(view, facing, props.onReady)
66
+ } catch (error: any) {
67
+ fail(error?.message ?? 'camera error')
68
+ }
69
+ })
70
+
71
+ return () => {
72
+ disposed = true
73
+ stopPreview?.()
74
+ }
75
+ }, [active, facing, props.session])
76
+
77
+ <gridlayout
78
+ ref={bindHost}
79
+ id={props.id}
80
+ rows="*"
81
+ columns="*"
82
+ className={cx('vx-layers', 'vx-cameraview', props.className)}
83
+ style={{ backgroundColor: '#000000', ...props.style }}
84
+ {...layoutChildProps(props)}
85
+ accessible={props.accessible}
86
+ accessibilityLabel={props.accessibilityLabel}
87
+ accessibilityRole={nativeAccessibilityRole(props.accessibilityRole) as any}
88
+ accessibilityHint={props.accessibilityHint}
89
+ accessibilityValue={props.accessibilityValue}
90
+ accessibilityState={nativeAccessibilityState(props.accessibilityState) as any}
91
+ accessibilityLiveRegion={props.accessibilityLiveRegion as any}
92
+ />
93
+ }