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