@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,1258 @@
1
+ import { File, knownFolders, path } from '@nativescript/core'
2
+ import { CameraCaptureError } from './types'
3
+ import { canonicalizePath } from './session-core'
4
+ import type {
5
+ BackendCaptureRequest,
6
+ BackendCaptureSink,
7
+ BackendObserver,
8
+ BackendPreviewListener,
9
+ CameraSessionBackend,
10
+ } from './session-core'
11
+
12
+ import type {
13
+ CameraDeviceInfo,
14
+ CameraInterruptionReason,
15
+ CameraOrientation,
16
+ CameraPermissionKind,
17
+ CameraPermissionStatus,
18
+ CaptureIssue,
19
+ CameraSessionConfig,
20
+ EffectiveCaptureConfig,
21
+ MovieOutputHandle,
22
+ } from './types'
23
+
24
+ import {
25
+ AVCaptureDevice,
26
+ AVCaptureDeviceDiscoverySession,
27
+ AVCaptureDeviceInput,
28
+ AVCaptureMovieFileOutput,
29
+ AVCaptureSession,
30
+ AVCaptureVideoPreviewLayer,
31
+ AVURLAsset,
32
+ CGPointMake,
33
+ NSBundle,
34
+ NSError,
35
+ NSFileManager,
36
+ NSNotification,
37
+ NSNotificationCenter,
38
+ NSNumber,
39
+ NSObject,
40
+ NSOperationQueue,
41
+ NSURL,
42
+ } from '@octane-xplat/platform/ios'
43
+
44
+ import { AVCaptureFileOutputRecordingDelegate } from '@octane-xplat/platform/ios'
45
+ import type { CGAffineTransform, NSObjectProtocol } from '@octane-xplat/platform/ios'
46
+
47
+ // AVAuthorizationStatus numeric values — the ambient const enum cannot be
48
+ // read under verbatimModuleSyntax in every build lane.
49
+ const authorizationNotDetermined = 0
50
+ const authorizationRestricted = 1
51
+ const authorizationDenied = 2
52
+ const authorizationAuthorized = 3
53
+
54
+ // CoreMedia sits outside the common NativeScript iOS ambient set — declare
55
+ // the handful of C functions this adapter needs instead of referencing the
56
+ // full typings.
57
+ declare function CMTimeGetSeconds(time: any): number
58
+ declare function CMTimeMakeWithSeconds(seconds: number, preferredTimescale: number): any
59
+ declare function CMVideoFormatDescriptionGetDimensions(videoDesc: any): {
60
+ width: number
61
+ height: number
62
+ }
63
+
64
+ // Ambient const enums cannot be read under verbatimModuleSyntax — keep the
65
+ // numeric values here with their Objective-C names.
66
+ const captureDevicePositionUnspecified = 0
67
+ const captureDevicePositionBack = 1
68
+ const captureDevicePositionFront = 2
69
+ const videoOrientationPortrait = 1
70
+ const videoOrientationPortraitUpsideDown = 2
71
+ const videoOrientationLandscapeRight = 3
72
+ const videoOrientationLandscapeLeft = 4
73
+ const interruptionVideoDeviceNotAvailableInBackground = 1
74
+ const interruptionAudioDeviceInUseByAnotherClient = 2
75
+ const interruptionVideoDeviceInUseByAnotherClient = 3
76
+ const interruptionVideoDeviceNotAvailableWithMultipleForegroundApps = 4
77
+ const interruptionVideoDeviceNotAvailableDueToSystemPressure = 5
78
+ const avErrorMaximumDurationReached = -11810
79
+ const videoStabilizationModeAuto = -1
80
+
81
+ // AVCaptureFocusMode/AVCaptureExposureMode numeric values — tap-to-focus
82
+ // uses the single-shot modes, matching the AVCam convention.
83
+ const focusModeAutoFocus = 1
84
+ const exposureModeAutoExpose = 1
85
+
86
+ const mediaTypeFor = (kind: CameraPermissionKind): string =>
87
+ kind === 'camera' ? AVMediaTypeVideo : AVMediaTypeAudio
88
+
89
+ const usageKeyFor = (kind: CameraPermissionKind): string =>
90
+ kind === 'camera' ? 'NSCameraUsageDescription' : 'NSMicrophoneUsageDescription'
91
+
92
+ const readPermission = (kind: CameraPermissionKind): CameraPermissionStatus => {
93
+ const status = AVCaptureDevice.authorizationStatusForMediaType(
94
+ mediaTypeFor(kind),
95
+ ) as unknown as number
96
+
97
+ switch (status) {
98
+ case authorizationAuthorized:
99
+ return 'granted'
100
+ case authorizationDenied:
101
+ return 'denied'
102
+ case authorizationRestricted:
103
+ return 'restricted'
104
+ case authorizationNotDetermined:
105
+ return 'notDetermined'
106
+ default:
107
+ return 'unknown'
108
+ }
109
+ }
110
+
111
+ const requestAccess = (kind: CameraPermissionKind): Promise<CameraPermissionStatus> => {
112
+ const declaration = NSBundle.mainBundle.objectForInfoDictionaryKey(usageKeyFor(kind))
113
+ if (!declaration) {
114
+ // Requesting access without the usage-description key crashes the
115
+ // host app — refuse before reaching AVFoundation.
116
+ return Promise.reject(
117
+ new CameraCaptureError({
118
+ kind: 'configurationMissing',
119
+ operation: 'requestPermission',
120
+ permission: kind,
121
+ message: `Missing ${usageKeyFor(kind)} in the host app's Info.plist`,
122
+ }),
123
+ )
124
+ }
125
+
126
+ const status = readPermission(kind)
127
+ if (status !== 'notDetermined') {
128
+ // Repeated requests must not pretend to show a prompt the system
129
+ // will not display.
130
+ return Promise.resolve(status)
131
+ }
132
+
133
+ return new Promise<CameraPermissionStatus>((resolve) => {
134
+ AVCaptureDevice.requestAccessForMediaTypeCompletionHandler(mediaTypeFor(kind), () =>
135
+ resolve(readPermission(kind)),
136
+ )
137
+ })
138
+ }
139
+
140
+ const facingForPosition = (position: number): 'front' | 'back' | undefined =>
141
+ position === captureDevicePositionFront
142
+ ? 'front'
143
+ : position === captureDevicePositionBack
144
+ ? 'back'
145
+ : undefined
146
+
147
+ const listCameras = (): AVCaptureDevice[] => {
148
+ const types = [
149
+ AVCaptureDeviceTypeBuiltInWideAngleCamera,
150
+ AVCaptureDeviceTypeBuiltInUltraWideCamera,
151
+ AVCaptureDeviceTypeBuiltInTelephotoCamera,
152
+ AVCaptureDeviceTypeBuiltInDualCamera,
153
+ AVCaptureDeviceTypeBuiltInDualWideCamera,
154
+ AVCaptureDeviceTypeBuiltInTripleCamera,
155
+ AVCaptureDeviceTypeBuiltInTrueDepthCamera,
156
+ ].filter((entry) => typeof entry === 'string')
157
+
158
+ const discovery =
159
+ AVCaptureDeviceDiscoverySession.discoverySessionWithDeviceTypesMediaTypePosition(
160
+ types,
161
+ AVMediaTypeVideo,
162
+ captureDevicePositionUnspecified,
163
+ )
164
+
165
+ const devices: AVCaptureDevice[] = []
166
+ const found = discovery?.devices
167
+ const count = found?.count ?? 0
168
+ for (let index = 0; index < count; index++) {
169
+ devices.push(found.objectAtIndex(index))
170
+ }
171
+
172
+ if (!devices.length) {
173
+ // Older runtime or no discovery support — fall back to the flat
174
+ // device list so simulator-style "no camera" stays honest.
175
+ const legacy = AVCaptureDevice.devicesWithMediaType(AVMediaTypeVideo)
176
+ for (let index = 0; index < (legacy?.count ?? 0); index++) {
177
+ devices.push(legacy.objectAtIndex(index))
178
+ }
179
+ }
180
+
181
+ return devices
182
+ }
183
+
184
+ const selectDevice = (
185
+ camera: CameraSessionConfig['camera'],
186
+ devices: AVCaptureDevice[],
187
+ ): AVCaptureDevice | undefined => {
188
+ if (camera && typeof camera === 'object' && 'deviceId' in camera) {
189
+ return devices.find((device) => device.uniqueID === camera.deviceId)
190
+ }
191
+
192
+ if (camera === 'front' || camera === 'back') {
193
+ const position = camera === 'front' ? captureDevicePositionFront : captureDevicePositionBack
194
+ return devices.find((device) => device.position === position)
195
+ }
196
+
197
+ return devices.find((device) => device.position === captureDevicePositionBack) ?? devices[0]
198
+ }
199
+
200
+ const orientationToRotationAngle = (orientation: CameraOrientation): number | undefined =>
201
+ orientation === 'portrait'
202
+ ? 90
203
+ : orientation === 'portraitUpsideDown'
204
+ ? 270
205
+ : orientation === 'landscapeLeft'
206
+ ? 180
207
+ : orientation === 'landscapeRight'
208
+ ? 0
209
+ : undefined
210
+
211
+ const orientationToLegacyConstant = (orientation: CameraOrientation): number | undefined =>
212
+ orientation === 'portrait'
213
+ ? videoOrientationPortrait
214
+ : orientation === 'portraitUpsideDown'
215
+ ? videoOrientationPortraitUpsideDown
216
+ : orientation === 'landscapeLeft'
217
+ ? videoOrientationLandscapeLeft
218
+ : orientation === 'landscapeRight'
219
+ ? videoOrientationLandscapeRight
220
+ : undefined
221
+
222
+ /** Derive the cardinal capture orientation from a video track's
223
+ * preferred transform; `undefined` when it cannot be told honestly. */
224
+ const orientationFromTransform = (transform: CGAffineTransform): CameraOrientation => {
225
+ const { a, b, c, d } = transform
226
+ if (a === 0 && b === 1 && c === -1 && d === 0) {
227
+ return 'portrait'
228
+ }
229
+
230
+ if (a === 0 && b === -1 && c === 1 && d === 0) {
231
+ return 'portraitUpsideDown'
232
+ }
233
+
234
+ if (a === 1 && b === 0 && c === 0 && d === 1) {
235
+ return 'landscapeRight'
236
+ }
237
+
238
+ if (a === -1 && b === 0 && c === 0 && d === -1) {
239
+ return 'landscapeLeft'
240
+ }
241
+
242
+ return 'unspecified'
243
+ }
244
+
245
+ /** Prefer Apple's automatic stabilization on a capture connection when the
246
+ * hardware offers it; unsupported connections keep the runtime default.
247
+ * `isVideoStabilizationModeSupported:` is missing from the generated {N}
248
+ * typings, so it is invoked dynamically with `supportsVideoStabilization`
249
+ * as the older-runtime fallback. */
250
+ const preferAutoStabilization = (connection: AVCaptureConnection | undefined | null) => {
251
+ if (!connection) {
252
+ return
253
+ }
254
+
255
+ const check = (connection as any).isVideoStabilizationModeSupported
256
+ const supported =
257
+ typeof check === 'function'
258
+ ? Boolean(check.call(connection, videoStabilizationModeAuto))
259
+ : Boolean(connection.supportsVideoStabilization)
260
+
261
+ if (supported) {
262
+ connection.preferredVideoStabilizationMode = videoStabilizationModeAuto
263
+ }
264
+ }
265
+
266
+ const mapInterruptionReason = (raw: number | undefined): CameraInterruptionReason => {
267
+ switch (raw) {
268
+ case interruptionVideoDeviceNotAvailableInBackground:
269
+ return 'background'
270
+ case interruptionAudioDeviceInUseByAnotherClient:
271
+ return 'audioInUse'
272
+ case interruptionVideoDeviceInUseByAnotherClient:
273
+ return 'cameraInUse'
274
+ case interruptionVideoDeviceNotAvailableWithMultipleForegroundApps:
275
+ return 'multitasking'
276
+ case interruptionVideoDeviceNotAvailableDueToSystemPressure:
277
+ return 'systemPressure'
278
+ default:
279
+ return 'unknown'
280
+ }
281
+ }
282
+
283
+ const recordingsDirectory = (): string => {
284
+ const directory = path.join(knownFolders.documents().path, 'octane-camera')
285
+ if (!File.exists(directory)) {
286
+ NSFileManager.defaultManager.createDirectoryAtPathWithIntermediateDirectoriesAttributesError(
287
+ directory,
288
+ true,
289
+ null,
290
+ undefined,
291
+ )
292
+ }
293
+
294
+ return directory
295
+ }
296
+
297
+ const hasUsageDeclaration = (kind: CameraPermissionKind): boolean =>
298
+ Boolean(NSBundle.mainBundle.objectForInfoDictionaryKey(usageKeyFor(kind)))
299
+
300
+ const minFreeDiskBytes = 64 * 1024 * 1024
301
+
302
+ /** Values returned through `id`-typed APIs (NSDictionary.objectForKey) arrive
303
+ * already unboxed by the {N} iOS runtime — NSNumber becomes a JS number (or
304
+ * boolean), not an NSNumber instance. Accept either shape so numeric reads
305
+ * stay correct under both conventions. */
306
+ const numericValue = (value: unknown): number | undefined => {
307
+ try {
308
+ if (typeof value === 'number') {
309
+ return value
310
+ }
311
+
312
+ if (typeof value === 'boolean') {
313
+ return value ? 1 : 0
314
+ }
315
+
316
+ const boxed = value as NSNumber | undefined
317
+ const raw = boxed?.longLongValue ?? boxed?.doubleValue
318
+ return typeof raw === 'number' ? raw : undefined
319
+ } catch {
320
+ return undefined
321
+ }
322
+ }
323
+
324
+ const freeDiskBytes = (): number => {
325
+ try {
326
+ const attributes = NSFileManager.defaultManager.attributesOfFileSystemForPathError(
327
+ knownFolders.documents().path,
328
+ undefined,
329
+ )
330
+
331
+ return numericValue(attributes?.objectForKey(NSFileSystemFreeSize)) ?? Number.MAX_SAFE_INTEGER
332
+ } catch {
333
+ return Number.MAX_SAFE_INTEGER
334
+ }
335
+ }
336
+
337
+ /** iOS adapter: one AVCaptureSession shared between the attached preview
338
+ * and a single AVCaptureMovieFileOutput capture attempt. */
339
+ export function createSessionBackend(config: CameraSessionConfig): CameraSessionBackend {
340
+ let currentConfig = { ...config }
341
+ let session: AVCaptureSession | undefined
342
+ let videoInput: AVCaptureDeviceInput | undefined
343
+ let audioInput: AVCaptureDeviceInput | undefined
344
+ let movieOutput: AVCaptureMovieFileOutput | undefined
345
+ let previewLayer: AVCaptureVideoPreviewLayer | undefined
346
+ let previewHost: any
347
+ let detachFrame: (() => void) | undefined
348
+ let previewReadyFlag = false
349
+ let device: AVCaptureDevice | undefined
350
+ let observer: BackendObserver | undefined
351
+ let observers: NSObjectProtocol[] = []
352
+ let captureSink: BackendCaptureSink | undefined
353
+ let captureRequest: BackendCaptureRequest | undefined
354
+ let limitRequested = false
355
+ let noDurationLimit: any
356
+ let interrupted = false
357
+
358
+ const RecordingDelegate = (NSObject as any).extend(
359
+ {
360
+ captureOutputDidStartRecordingToOutputFileAtURLFromConnections(
361
+ _output: unknown,
362
+ _url: unknown,
363
+ _connections: unknown,
364
+ ) {
365
+ onCaptureStarted()
366
+ },
367
+ captureOutputDidFinishRecordingToOutputFileAtURLFromConnectionsError(
368
+ _output: unknown,
369
+ url: NSURL,
370
+ _connections: unknown,
371
+ error: NSError | null,
372
+ ) {
373
+ onCaptureFinished(url, error)
374
+ },
375
+ },
376
+ { protocols: [AVCaptureFileOutputRecordingDelegate] },
377
+ )
378
+
379
+ const recordingDelegate = RecordingDelegate.new()
380
+
381
+ const emitIssue = (
382
+ kind: CaptureIssue['kind'],
383
+ operation: string,
384
+ message: string,
385
+ cause?: unknown,
386
+ ): CaptureIssue => ({
387
+ kind,
388
+ operation,
389
+ message,
390
+ cause,
391
+ })
392
+
393
+ const notify = (phase: 'began' | 'ended', reason?: CameraInterruptionReason) => {
394
+ interrupted = phase === 'began'
395
+ observer?.interruption(phase, reason)
396
+ observer?.previewReady(previewReadyFlag && !interrupted)
397
+ }
398
+
399
+ const observeSession = (target: AVCaptureSession) => {
400
+ const center = NSNotificationCenter.defaultCenter
401
+ const add = (name: string, object: unknown, handler: (note: NSNotification) => void) => {
402
+ observers.push(
403
+ center.addObserverForNameObjectQueueUsingBlock(
404
+ name,
405
+ object as any,
406
+ NSOperationQueue.mainQueue,
407
+ handler,
408
+ ),
409
+ )
410
+ }
411
+
412
+ add(AVCaptureSessionWasInterruptedNotification, target, (note) => {
413
+ const raw = note.userInfo?.objectForKey(AVCaptureSessionInterruptionReasonKey)
414
+
415
+ notify('began', mapInterruptionReason(numericValue(raw)))
416
+ })
417
+
418
+ add(AVCaptureSessionInterruptionEndedNotification, target, () => {
419
+ notify('ended')
420
+ })
421
+
422
+ add(AVCaptureSessionRuntimeErrorNotification, target, () => {
423
+ observer?.availability(false)
424
+ })
425
+
426
+ // Filter by the ACTIVE device — `device` is reassigned on
427
+ // configure(), so an observer bound to the captured object would
428
+ // keep reporting on the stale one while the active camera's
429
+ // disconnect went unreported (and any device's connect reported
430
+ // availability(true)).
431
+ add(AVCaptureDeviceWasDisconnectedNotification, null, (note) => {
432
+ if (device && note.object === device) {
433
+ observer?.availability(false)
434
+ }
435
+ })
436
+
437
+ add(AVCaptureDeviceWasConnectedNotification, null, (note) => {
438
+ if (device && note.object === device) {
439
+ observer?.availability(true)
440
+ }
441
+ })
442
+
443
+ add(UIApplicationDidEnterBackgroundNotification, null, () => {
444
+ notify('began', 'background')
445
+ })
446
+
447
+ add(UIApplicationWillEnterForegroundNotification, null, () => {
448
+ notify('ended', 'background')
449
+ })
450
+ }
451
+
452
+ const clearObservers = () => {
453
+ const center = NSNotificationCenter.defaultCenter
454
+ for (const entry of observers) {
455
+ center.removeObserver(entry)
456
+ }
457
+
458
+ observers = []
459
+ }
460
+
461
+ const supportedPreset = (target: AVCaptureSession, captureDevice: AVCaptureDevice) =>
462
+ target.canSetSessionPreset(AVCaptureSessionPreset1280x720) &&
463
+ captureDevice.supportsAVCaptureSessionPreset(AVCaptureSessionPreset1280x720)
464
+
465
+ /** Resolve the effective standard profile for the current camera:
466
+ * 720p/30 when the device supports it, otherwise the device's own
467
+ * active format disclosed honestly. */
468
+ const standardProfile = (captureDevice: AVCaptureDevice | undefined) => {
469
+ if (captureDevice && session && supportedPreset(session, captureDevice)) {
470
+ return { width: 1280, height: 720, frameRate: 30 }
471
+ }
472
+
473
+ const format = captureDevice?.activeFormat
474
+ const dimensions = format
475
+ ? CMVideoFormatDescriptionGetDimensions(format.formatDescription)
476
+ : undefined
477
+
478
+ const range = captureDevice?.activeVideoMinFrameDuration
479
+ const frameRate =
480
+ range && range.timescale > 0 && range.value > 0
481
+ ? Math.round(range.timescale / range.value)
482
+ : 30
483
+
484
+ return {
485
+ width: dimensions?.width ?? 1280,
486
+ height: dimensions?.height ?? 720,
487
+ frameRate,
488
+ }
489
+ }
490
+
491
+ const ensureAudioInput = (target: AVCaptureSession): boolean => {
492
+ if (audioInput) {
493
+ return true
494
+ }
495
+
496
+ const mic = AVCaptureDevice.defaultDeviceWithMediaType(AVMediaTypeAudio)
497
+ if (!mic) {
498
+ return false
499
+ }
500
+
501
+ const input = AVCaptureDeviceInput.deviceInputWithDeviceError(mic, undefined)
502
+ if (!input || !target.canAddInput(input)) {
503
+ return false
504
+ }
505
+
506
+ target.addInput(input)
507
+ audioInput = input
508
+ return true
509
+ }
510
+
511
+ const ensureSession = (): AVCaptureSession => {
512
+ if (session) {
513
+ return session
514
+ }
515
+
516
+ const target = AVCaptureSession.new()
517
+ target.beginConfiguration()
518
+ device = selectDevice(currentConfig.camera, listCameras())
519
+ if (!device) {
520
+ target.commitConfiguration()
521
+ throw new CameraCaptureError({
522
+ kind: 'unavailable',
523
+ operation: 'startPreview',
524
+ message: 'No camera device is available',
525
+ })
526
+ }
527
+
528
+ if (supportedPreset(target, device)) {
529
+ target.sessionPreset = AVCaptureSessionPreset1280x720
530
+ }
531
+
532
+ const input = AVCaptureDeviceInput.deviceInputWithDeviceError(device, undefined)
533
+ if (!input || !target.canAddInput(input)) {
534
+ target.commitConfiguration()
535
+ throw new CameraCaptureError({
536
+ kind: 'unavailable',
537
+ operation: 'startPreview',
538
+ message: 'Camera input is unavailable',
539
+ })
540
+ }
541
+
542
+ target.addInput(input)
543
+ videoInput = input
544
+ const output = AVCaptureMovieFileOutput.new()
545
+ if (target.canAddOutput(output)) {
546
+ target.addOutput(output)
547
+ movieOutput = output
548
+ noDurationLimit = output.maxRecordedDuration
549
+ } else {
550
+ target.commitConfiguration()
551
+ throw new CameraCaptureError({
552
+ kind: 'unsupportedPlatform',
553
+ operation: 'startPreview',
554
+ message: 'Movie file output is unavailable on this device',
555
+ })
556
+ }
557
+
558
+ if (currentConfig.audio && readPermission('microphone') === 'granted') {
559
+ ensureAudioInput(target)
560
+ }
561
+
562
+ target.commitConfiguration()
563
+ session = target
564
+ observeSession(target)
565
+ return target
566
+ }
567
+
568
+ const stopSessionIfIdle = () => {
569
+ if (!session || captureSink || previewHost) {
570
+ return
571
+ }
572
+
573
+ session.stopRunning()
574
+ clearObservers()
575
+ if (interrupted) {
576
+ // The 'ended' notification can be lost when the session is
577
+ // torn down mid-interruption — report it so the flag does not
578
+ // wedge previewReady/startRecording for the next session.
579
+ notify('ended')
580
+ }
581
+
582
+ // A destroyed session's runtime error must not wedge the next
583
+ // one — AVCaptureDeviceWasConnected never fires for built-in
584
+ // cameras, so cameraAvailable would otherwise stay false forever.
585
+ observer?.availability(true)
586
+
587
+ session = undefined
588
+ videoInput = undefined
589
+ audioInput = undefined
590
+ movieOutput = undefined
591
+ previewLayer = undefined
592
+ device = undefined
593
+ }
594
+
595
+ const onCaptureStarted = () => {
596
+ const request = captureRequest
597
+ const sink = captureSink
598
+ if (!request || !sink || !device) {
599
+ return
600
+ }
601
+
602
+ const profile = standardProfile(device)
603
+ const configuration: EffectiveCaptureConfig = {
604
+ cameraId: device.uniqueID,
605
+ facing: facingForPosition(device.position),
606
+ audio: request.audio && audioInput !== undefined,
607
+ width: profile.width,
608
+ height: profile.height,
609
+ frameRate: profile.frameRate,
610
+ orientation: request.orientation ?? 'unspecified',
611
+ mimeType: 'video/quicktime',
612
+ container: 'mov',
613
+ }
614
+
615
+ sink.started(configuration)
616
+ }
617
+
618
+ const onCaptureFinished = (url: NSURL, error: NSError | null) => {
619
+ const request = captureRequest
620
+ const sink = captureSink
621
+ captureRequest = undefined
622
+ captureSink = undefined
623
+ if (!request || !sink) {
624
+ return
625
+ }
626
+
627
+ const filePath = url?.path ? String(url.path) : ''
628
+ const succeeded =
629
+ !error ||
630
+ numericValue(error.userInfo?.objectForKey(AVErrorRecordingSuccessfullyFinishedKey)) === 1
631
+
632
+ if (!succeeded) {
633
+ // Clean up only the file this attempt created — never a
634
+ // pre-existing app file (admission rejects existing paths).
635
+ try {
636
+ if (filePath && File.exists(filePath)) {
637
+ NSFileManager.defaultManager.removeItemAtPathError(filePath, undefined)
638
+ }
639
+ } catch {
640
+ // Temporary leftovers are reclaimed by the OS.
641
+ }
642
+
643
+ sink.settled({
644
+ kind: 'failed',
645
+ stage: 'capture',
646
+ issue: emitIssue(
647
+ 'captureFailed',
648
+ 'finalize',
649
+ error?.localizedDescription ?? 'Movie recording did not complete',
650
+ error,
651
+ ),
652
+ })
653
+
654
+ stopSessionIfIdle()
655
+ return
656
+ }
657
+
658
+ const limitReached = limitRequested && error?.code === avErrorMaximumDurationReached
659
+ try {
660
+ const asset = AVURLAsset.assetWithURL(url)
661
+ const durationSeconds = asset ? CMTimeGetSeconds(asset.duration) : 0
662
+ const videoTracks = asset?.tracksWithMediaType(AVMediaTypeVideo)
663
+ const audioTracks = asset?.tracksWithMediaType(AVMediaTypeAudio)
664
+ const track = videoTracks && videoTracks.count > 0 ? videoTracks.objectAtIndex(0) : null
665
+ if (!track || !(durationSeconds > 0)) {
666
+ try {
667
+ if (filePath && File.exists(filePath)) {
668
+ NSFileManager.defaultManager.removeItemAtPathError(filePath, undefined)
669
+ }
670
+ } catch {
671
+ // Temporary leftovers are reclaimed by the OS.
672
+ }
673
+
674
+ sink.settled({
675
+ kind: 'failed',
676
+ stage: 'capture',
677
+ issue: emitIssue('noMedia', 'finalize', 'The finalized movie contains no playable media'),
678
+ })
679
+
680
+ stopSessionIfIdle()
681
+ return
682
+ }
683
+
684
+ const naturalSize = track.naturalSize
685
+ const transform = track.preferredTransform
686
+ const orientation =
687
+ request.orientation && request.orientation !== 'unspecified'
688
+ ? request.orientation
689
+ : orientationFromTransform(transform)
690
+
691
+ const rotated = Math.abs(transform.b) === 1 && Math.abs(transform.c) === 1
692
+ const width = rotated ? naturalSize.height : naturalSize.width
693
+ const height = rotated ? naturalSize.width : naturalSize.height
694
+ if (request.audio && !(audioTracks && audioTracks.count > 0)) {
695
+ // Requested audio missing must not masquerade as silent
696
+ // capture — classify as failure, not a successful clip.
697
+ try {
698
+ if (filePath && File.exists(filePath)) {
699
+ NSFileManager.defaultManager.removeItemAtPathError(filePath, undefined)
700
+ }
701
+ } catch {
702
+ // Temporary leftovers are reclaimed by the OS.
703
+ }
704
+
705
+ sink.settled({
706
+ kind: 'failed',
707
+ stage: 'capture',
708
+ issue: emitIssue(
709
+ 'captureFailed',
710
+ 'finalize',
711
+ 'Requested audio track is missing from the finalized movie',
712
+ ),
713
+ })
714
+
715
+ stopSessionIfIdle()
716
+ return
717
+ }
718
+
719
+ sink.settled({
720
+ kind: 'clip',
721
+ output: {
722
+ kind: 'nativeFile',
723
+ resourceId: `file:${filePath}`,
724
+ fileUrl: `file://${filePath}`,
725
+ },
726
+ mimeType: 'video/quicktime',
727
+ durationMs: Math.round(durationSeconds * 1000),
728
+ width,
729
+ height,
730
+ orientation,
731
+ hasAudio: request.audio && (audioTracks?.count ?? 0) > 0,
732
+ limitReached,
733
+ })
734
+ } catch (cause) {
735
+ sink.settled({
736
+ kind: 'failed',
737
+ stage: 'finalization',
738
+ issue: emitIssue(
739
+ 'finalizationFailed',
740
+ 'finalize',
741
+ cause instanceof Error ? cause.message : String(cause),
742
+ cause,
743
+ ),
744
+ })
745
+ }
746
+
747
+ stopSessionIfIdle()
748
+ }
749
+
750
+ return {
751
+ platform: 'ios',
752
+ supported: typeof AVCaptureMovieFileOutput !== 'undefined',
753
+ checkPermission: async (kind) => {
754
+ if (!hasUsageDeclaration(kind)) {
755
+ // Missing declarations are a setup error, not a permission
756
+ // status — surface through start/configure failures instead of
757
+ // pretending a status.
758
+ return 'unknown'
759
+ }
760
+
761
+ return readPermission(kind)
762
+ },
763
+ requestPermission: (kind) => requestAccess(kind),
764
+ getCapabilities: async () => {
765
+ const devices = listCameras()
766
+ const cameras: CameraDeviceInfo[] = devices.map((entry) => ({
767
+ id: entry.uniqueID,
768
+ label: entry.localizedName ?? entry.uniqueID,
769
+ facing: facingForPosition(entry.position),
770
+ }))
771
+
772
+ const selected = device ?? selectDevice(currentConfig.camera, devices)
773
+ const profile = selected
774
+ ? (() => {
775
+ const probe = AVCaptureSession.new()
776
+ probe.beginConfiguration()
777
+ const supported = supportedPreset(probe, selected)
778
+ probe.commitConfiguration()
779
+ return supported
780
+ ? { width: 1280, height: 720, frameRate: 30 }
781
+ : standardProfile(selected)
782
+ })()
783
+ : { width: 1280, height: 720, frameRate: 30 }
784
+
785
+ const available = cameras.length > 0
786
+ const supported = typeof AVCaptureMovieFileOutput !== 'undefined' && available
787
+ return {
788
+ supported,
789
+ reason: !available
790
+ ? 'No camera device is available on this host'
791
+ : typeof AVCaptureMovieFileOutput === 'undefined'
792
+ ? 'Movie file output is unsupported by this runtime'
793
+ : undefined,
794
+ available,
795
+ cameras,
796
+ profiles: [{ profile: 'standard', ...profile }],
797
+ audio: typeof AVCaptureDevice.defaultDeviceWithMediaType === 'function',
798
+ focusPointOfInterest: selected?.focusPointOfInterestSupported === true,
799
+ exposurePointOfInterest: selected?.exposurePointOfInterestSupported === true,
800
+ orientations: ['portrait', 'portraitUpsideDown', 'landscapeLeft', 'landscapeRight'],
801
+ durationLimit: 'native',
802
+ elapsedTime: 'nativeMedia',
803
+ output: {
804
+ mimeType: 'video/quicktime',
805
+ container: 'mov',
806
+ storage: 'appPrivateFile',
807
+ destinationFileUrl: true,
808
+ },
809
+ }
810
+ },
811
+ previewReady: () => previewReadyFlag && !interrupted,
812
+ attachPreview: (host, listener?: BackendPreviewListener) => {
813
+ let target: AVCaptureSession
814
+ try {
815
+ target = ensureSession()
816
+ } catch (error) {
817
+ throw error instanceof CameraCaptureError
818
+ ? error
819
+ : new CameraCaptureError({
820
+ kind: 'unavailable',
821
+ operation: 'startPreview',
822
+ message: error instanceof Error ? error.message : String(error),
823
+ cause: error,
824
+ })
825
+ }
826
+
827
+ const view = host as any
828
+ const nativeView = view?.ios ?? view?.nativeViewProtected
829
+ if (!nativeView?.layer) {
830
+ throw new CameraCaptureError({
831
+ kind: 'invalidArgument',
832
+ operation: 'attachPreview',
833
+ message: 'Camera preview host is unavailable',
834
+ })
835
+ }
836
+
837
+ const layer = AVCaptureVideoPreviewLayer.layerWithSession(target)
838
+ layer.videoGravity = AVLayerVideoGravityResizeAspectFill
839
+ const connection = layer.connection
840
+ if (connection) {
841
+ connection.automaticallyAdjustsVideoMirroring = false
842
+ if (connection.supportsVideoMirroring) {
843
+ connection.videoMirrored = device?.position === captureDevicePositionFront
844
+ }
845
+
846
+ preferAutoStabilization(connection)
847
+ }
848
+
849
+ nativeView.layer.addSublayer(layer)
850
+ previewLayer = layer
851
+ previewHost = view
852
+ const updateFrame = () => {
853
+ layer.frame = nativeView.bounds
854
+ }
855
+
856
+ detachFrame = () => view.off?.('layoutChanged', updateFrame)
857
+ view.on?.('layoutChanged', updateFrame)
858
+ updateFrame()
859
+ target.startRunning()
860
+ previewReadyFlag = true
861
+ observer?.previewReady(true)
862
+ listener?.onReady?.()
863
+ return () => {
864
+ previewReadyFlag = false
865
+ view.off?.('layoutChanged', updateFrame)
866
+ layer.removeFromSuperlayer()
867
+ previewLayer = undefined
868
+ previewHost = undefined
869
+ detachFrame = undefined
870
+ observer?.previewReady(false)
871
+ stopSessionIfIdle()
872
+ }
873
+ },
874
+ configure: (next) => {
875
+ const previous = currentConfig
876
+ currentConfig = { ...next }
877
+ const target = session
878
+ if (!target || !device) {
879
+ return
880
+ }
881
+
882
+ target.beginConfiguration()
883
+ const wanted = selectDevice(next.camera ?? previous.camera, listCameras())
884
+ if (wanted && wanted !== device && videoInput) {
885
+ const input = AVCaptureDeviceInput.deviceInputWithDeviceError(wanted, undefined)
886
+ if (input && target.canAddInput(input)) {
887
+ target.removeInput(videoInput)
888
+ target.addInput(input)
889
+ videoInput = input
890
+ device = wanted
891
+ }
892
+ }
893
+
894
+ if (next.audio === true && readPermission('microphone') === 'granted') {
895
+ ensureAudioInput(target)
896
+ } else if (next.audio === false && audioInput) {
897
+ target.removeInput(audioInput)
898
+ audioInput = undefined
899
+ }
900
+
901
+ target.commitConfiguration()
902
+ // Swapping the video input recreates the preview connection — the
903
+ // stabilization preference must be restated.
904
+ preferAutoStabilization(previewLayer?.connection)
905
+ },
906
+ focusAtPoint: (point) => {
907
+ const captureDevice = device
908
+ const layer = previewLayer
909
+ if (!captureDevice || !layer) {
910
+ return
911
+ }
912
+
913
+ // Front cameras are commonly fixed-focus — meter only the
914
+ // dimensions the device actually supports.
915
+ const focus =
916
+ captureDevice.focusPointOfInterestSupported === true &&
917
+ captureDevice.isFocusModeSupported?.(focusModeAutoFocus) !== false
918
+
919
+ const expose =
920
+ captureDevice.exposurePointOfInterestSupported === true &&
921
+ captureDevice.isExposureModeSupported?.(exposureModeAutoExpose) !== false
922
+
923
+ if (!focus && !expose) {
924
+ return
925
+ }
926
+
927
+ // The preview layer converts view space into device space —
928
+ // aspect-fill cropping, sensor orientation, and the front
929
+ // camera's mirrored connection are all accounted for. Without
930
+ // it there is no honest mapping, so bail rather than meter the
931
+ // wrong point.
932
+ const bounds = layer.bounds
933
+ const layerPoint = CGPointMake(point.x * bounds.size.width, point.y * bounds.size.height)
934
+
935
+ if (typeof layer.captureDevicePointOfInterestForPoint !== 'function') {
936
+ return
937
+ }
938
+
939
+ const devicePoint = layer.captureDevicePointOfInterestForPoint(layerPoint)
940
+
941
+ try {
942
+ if (captureDevice.lockForConfiguration?.(undefined) !== true) {
943
+ return
944
+ }
945
+
946
+ // Single-shot AF/AE at the point — the Apple AVCam
947
+ // tap-to-focus convention; the device locks after metering.
948
+ if (focus) {
949
+ captureDevice.focusPointOfInterest = devicePoint
950
+ captureDevice.focusMode = focusModeAutoFocus
951
+ }
952
+
953
+ if (expose) {
954
+ captureDevice.exposurePointOfInterest = devicePoint
955
+ captureDevice.exposureMode = exposureModeAutoExpose
956
+ }
957
+
958
+ captureDevice.unlockForConfiguration()
959
+ } catch {
960
+ // A missed metering pass must not fault the session.
961
+ }
962
+ },
963
+ startCapture: (request, sink) => {
964
+ const target = session
965
+ if (!target || !movieOutput || !device) {
966
+ sink.settled({
967
+ kind: 'failed',
968
+ stage: 'preparation',
969
+ issue: emitIssue('unavailable', 'startRecording', 'The camera session is not running'),
970
+ })
971
+
972
+ return
973
+ }
974
+
975
+ if (!hasUsageDeclaration('camera')) {
976
+ sink.settled({
977
+ kind: 'failed',
978
+ stage: 'preparation',
979
+ issue: emitIssue(
980
+ 'configurationMissing',
981
+ 'startRecording',
982
+ "Missing NSCameraUsageDescription in the host app's Info.plist",
983
+ ),
984
+ })
985
+
986
+ return
987
+ }
988
+
989
+ if (request.audio) {
990
+ if (!hasUsageDeclaration('microphone')) {
991
+ sink.settled({
992
+ kind: 'failed',
993
+ stage: 'preparation',
994
+ issue: emitIssue(
995
+ 'configurationMissing',
996
+ 'startRecording',
997
+ "Missing NSMicrophoneUsageDescription in the host app's Info.plist",
998
+ ),
999
+ })
1000
+
1001
+ return
1002
+ }
1003
+
1004
+ const micStatus = readPermission('microphone')
1005
+ if (micStatus !== 'granted') {
1006
+ sink.settled({
1007
+ kind: 'failed',
1008
+ stage: 'preparation',
1009
+ issue: emitIssue(
1010
+ micStatus === 'restricted' ? 'permissionRestricted' : 'permissionDenied',
1011
+ 'startRecording',
1012
+ `Microphone permission is ${micStatus}`,
1013
+ { permission: 'microphone' },
1014
+ ),
1015
+ })
1016
+
1017
+ return
1018
+ }
1019
+
1020
+ if (!audioInput) {
1021
+ target.beginConfiguration()
1022
+ const added = ensureAudioInput(target)
1023
+ target.commitConfiguration()
1024
+ if (!added) {
1025
+ sink.settled({
1026
+ kind: 'failed',
1027
+ stage: 'preparation',
1028
+ issue: emitIssue(
1029
+ 'unavailable',
1030
+ 'startRecording',
1031
+ 'No audio input device is available',
1032
+ ),
1033
+ })
1034
+
1035
+ return
1036
+ }
1037
+ }
1038
+ }
1039
+
1040
+ const connection = movieOutput.connectionWithMediaType(AVMediaTypeVideo)
1041
+ if (request.orientation) {
1042
+ const angle = orientationToRotationAngle(request.orientation)
1043
+ const legacy = orientationToLegacyConstant(request.orientation)
1044
+ let applied = false
1045
+ if (
1046
+ connection &&
1047
+ angle !== undefined &&
1048
+ typeof connection.isVideoRotationAngleSupported === 'function' &&
1049
+ connection.isVideoRotationAngleSupported(angle)
1050
+ ) {
1051
+ connection.videoRotationAngle = angle
1052
+ applied = true
1053
+ } else if (connection && legacy !== undefined && connection.supportsVideoOrientation) {
1054
+ connection.videoOrientation = legacy
1055
+ applied = true
1056
+ }
1057
+
1058
+ if (!applied) {
1059
+ sink.settled({
1060
+ kind: 'failed',
1061
+ stage: 'preparation',
1062
+ issue: emitIssue(
1063
+ 'unsupportedConfiguration',
1064
+ 'startRecording',
1065
+ `Orientation '${request.orientation}' is not supported by this connection`,
1066
+ ),
1067
+ })
1068
+
1069
+ return
1070
+ }
1071
+ }
1072
+
1073
+ if (connection) {
1074
+ // Recorded output is unmirrored, including the front camera —
1075
+ // the preview may stay mirrored under its existing behavior.
1076
+ connection.automaticallyAdjustsVideoMirroring = false
1077
+ if (connection.supportsVideoMirroring) {
1078
+ connection.videoMirrored = false
1079
+ }
1080
+
1081
+ preferAutoStabilization(connection)
1082
+ }
1083
+
1084
+ let filePath: string
1085
+ if (request.destinationPath) {
1086
+ // Durable output stays app-private — canonicalize first so a
1087
+ // '..' segment cannot keep the Documents prefix while
1088
+ // resolving outside it.
1089
+ const destination = canonicalizePath(request.destinationPath)
1090
+ const documents = knownFolders.documents().path
1091
+ const inside = destination.startsWith(`${documents}/`)
1092
+ const directory = destination.slice(0, destination.lastIndexOf('/'))
1093
+ if (!inside || !File.exists(directory) || File.exists(destination)) {
1094
+ sink.settled({
1095
+ kind: 'failed',
1096
+ stage: 'preparation',
1097
+ issue: emitIssue(
1098
+ 'destinationUnavailable',
1099
+ 'startRecording',
1100
+ !inside
1101
+ ? 'destinationFileUrl must resolve inside the app-private documents directory'
1102
+ : File.exists(destination)
1103
+ ? 'destinationFileUrl already exists'
1104
+ : 'destinationFileUrl directory does not exist',
1105
+ ),
1106
+ })
1107
+
1108
+ return
1109
+ }
1110
+
1111
+ filePath = destination
1112
+ } else {
1113
+ try {
1114
+ filePath = path.join(
1115
+ recordingsDirectory(),
1116
+ `clip-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.mov`,
1117
+ )
1118
+ } catch (cause) {
1119
+ sink.settled({
1120
+ kind: 'failed',
1121
+ stage: 'preparation',
1122
+ issue: emitIssue(
1123
+ 'destinationUnavailable',
1124
+ 'startRecording',
1125
+ cause instanceof Error ? cause.message : String(cause),
1126
+ cause,
1127
+ ),
1128
+ })
1129
+
1130
+ return
1131
+ }
1132
+ }
1133
+
1134
+ if (freeDiskBytes() < minFreeDiskBytes) {
1135
+ sink.settled({
1136
+ kind: 'failed',
1137
+ stage: 'preparation',
1138
+ issue: emitIssue(
1139
+ 'insufficientStorage',
1140
+ 'startRecording',
1141
+ 'Not enough local storage for a movie recording',
1142
+ ),
1143
+ })
1144
+
1145
+ return
1146
+ }
1147
+
1148
+ limitRequested = request.maximumDurationMs !== undefined
1149
+ movieOutput.maxRecordedDuration =
1150
+ request.maximumDurationMs !== undefined
1151
+ ? CMTimeMakeWithSeconds(request.maximumDurationMs / 1000, 600)
1152
+ : (noDurationLimit ?? movieOutput.maxRecordedDuration)
1153
+
1154
+ captureRequest = request
1155
+ captureSink = sink
1156
+ try {
1157
+ movieOutput.startRecordingToOutputFileURLRecordingDelegate(
1158
+ NSURL.fileURLWithPath(filePath),
1159
+ recordingDelegate,
1160
+ )
1161
+ } catch (cause) {
1162
+ captureRequest = undefined
1163
+ captureSink = undefined
1164
+ sink.settled({
1165
+ kind: 'failed',
1166
+ stage: 'preparation',
1167
+ issue: emitIssue(
1168
+ 'captureFailed',
1169
+ 'startRecording',
1170
+ cause instanceof Error ? cause.message : String(cause),
1171
+ cause,
1172
+ ),
1173
+ })
1174
+ }
1175
+ },
1176
+ stopCapture: () => {
1177
+ try {
1178
+ if (movieOutput?.recording) {
1179
+ movieOutput.stopRecording()
1180
+ }
1181
+ } catch {
1182
+ // Settlement still arrives through the delegate callback.
1183
+ }
1184
+ },
1185
+ elapsedMs: () => {
1186
+ try {
1187
+ const duration = movieOutput?.recordedDuration
1188
+ if (!duration) {
1189
+ return 0
1190
+ }
1191
+
1192
+ return Math.max(0, Math.round(CMTimeGetSeconds(duration) * 1000))
1193
+ } catch {
1194
+ return 0
1195
+ }
1196
+ },
1197
+ openOutput: async (output) => {
1198
+ if (output.kind !== 'nativeFile') {
1199
+ throw new CameraCaptureError({
1200
+ kind: 'invalidArgument',
1201
+ operation: 'openOutput',
1202
+ message: 'Only file-backed output can be opened on iOS',
1203
+ })
1204
+ }
1205
+
1206
+ const filePath = output.fileUrl.slice('file://'.length)
1207
+ if (!File.exists(filePath)) {
1208
+ throw new CameraCaptureError({
1209
+ kind: 'unavailable',
1210
+ operation: 'openOutput',
1211
+ message: `Movie file is missing at ${output.fileUrl}`,
1212
+ })
1213
+ }
1214
+
1215
+ const handle: MovieOutputHandle = {
1216
+ url: output.fileUrl,
1217
+ fileUrl: output.fileUrl,
1218
+ release: () => {},
1219
+ }
1220
+
1221
+ return handle
1222
+ },
1223
+ release: async () => {
1224
+ previewReadyFlag = false
1225
+ detachFrame?.()
1226
+ detachFrame = undefined
1227
+ if (previewHost) {
1228
+ try {
1229
+ previewLayer?.removeFromSuperlayer()
1230
+ } catch {}
1231
+ }
1232
+
1233
+ previewHost = undefined
1234
+ previewLayer = undefined
1235
+ captureRequest = undefined
1236
+ captureSink = undefined
1237
+ clearObservers()
1238
+ interrupted = false
1239
+ try {
1240
+ session?.stopRunning()
1241
+ } catch {}
1242
+
1243
+ session = undefined
1244
+ videoInput = undefined
1245
+ audioInput = undefined
1246
+ movieOutput = undefined
1247
+ device = undefined
1248
+ },
1249
+ onPreviewDetached: () => {
1250
+ // The view went away; if a settle is still in flight the session
1251
+ // stays alive until it arrives (stopSessionIfIdle re-checks).
1252
+ stopSessionIfIdle()
1253
+ },
1254
+ setObserver: (next) => {
1255
+ observer = next
1256
+ },
1257
+ }
1258
+ }