@octane-xplat/camera 0.13.0 → 0.15.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 (58) hide show
  1. package/README.md +89 -3
  2. package/package.json +35 -11
  3. package/platforms/android/AndroidManifest.xml +2 -0
  4. package/platforms/android/include.gradle +1 -0
  5. package/platforms/android/java/com/octanexplat/camera/CameraPreviewView.java +13 -0
  6. package/platforms/android/java/com/octanexplat/camera/CameraSessionBridge.java +394 -0
  7. package/platforms/ios/Info.plist +2 -0
  8. package/platforms/macos/src/CameraOwnership.swift +22 -0
  9. package/platforms/macos/src/PhotoCaptureLifecycle.swift +34 -0
  10. package/platforms/macos/src/XplatCameraHost.swift +639 -0
  11. package/platforms/macos/src/XplatPhotoCaptureSheet.swift +192 -0
  12. package/src/CameraView.android.tsrx +27 -3
  13. package/src/CameraView.ios.tsrx +32 -4
  14. package/src/CameraView.macos.tsrx +95 -0
  15. package/src/CameraView.web.tsrx +31 -2
  16. package/src/CameraView.windows.tsrx +126 -0
  17. package/src/camera-permission.mobile.ts +18 -4
  18. package/src/camera-permission.windows.ts +76 -0
  19. package/src/camera-session.macos.ts +75 -0
  20. package/src/camera-session.windows.ts +126 -0
  21. package/src/host-movie.web.ts +295 -0
  22. package/src/index.ts +32 -0
  23. package/src/index.web.ts +32 -0
  24. package/src/layout-child.windows.ts +79 -0
  25. package/src/movie-metadata.web.ts +84 -0
  26. package/src/movie-storage.web.ts +164 -0
  27. package/src/preview-tap.ts +34 -0
  28. package/src/props.ts +14 -1
  29. package/src/session-backend.android.ts +439 -0
  30. package/src/session-backend.ios.ts +1258 -0
  31. package/src/session-backend.linux.ts +887 -0
  32. package/src/session-backend.macos.ts +707 -0
  33. package/src/session-backend.ts +59 -0
  34. package/src/session-backend.web.ts +891 -0
  35. package/src/session-backend.windows.ts +1599 -0
  36. package/src/session-core.ts +723 -0
  37. package/src/session.ts +37 -0
  38. package/src/synthetic-session.ts +377 -0
  39. package/src/types.ts +357 -0
  40. package/types/index.d.ts +43 -0
  41. package/types/props.d.ts +14 -1
  42. package/types/session-core.d.ts +139 -0
  43. package/types/synthetic-session.d.ts +51 -0
  44. package/types/types.d.ts +290 -0
  45. package/dist/native/CameraView.android.js +0 -166
  46. package/dist/native/CameraView.ios.js +0 -131
  47. package/dist/native/camera-permission.mobile.js +0 -45
  48. package/dist/native/camera-session.ios.js +0 -45
  49. package/dist/native/cx.mobile.js +0 -9
  50. package/dist/native/index.js +0 -2
  51. package/dist/native/layout-child.js +0 -53
  52. package/dist/native/octane-client-build.json +0 -8
  53. package/dist/web/CameraView.web.tsrx.js +0 -317
  54. package/dist/web/index.js +0 -2
  55. package/dist/web/layout-child.web.js +0 -29
  56. package/dist/web/octane-client-build.json +0 -8
  57. package/src/CameraView.windows.ts +0 -5
  58. package/src/cx.mobile.ts +0 -6
@@ -0,0 +1,707 @@
1
+ import '@nativescript/macos-node-api'
2
+
3
+ import { CameraCaptureError } from './types'
4
+ import { canonicalizePath } from './session-core'
5
+ import type { BackendCaptureRequest, BackendCaptureSink, BackendObserver } from './session-core'
6
+ import type { CameraSessionBackend } from './session-core'
7
+ import type { BackendPreviewListener } from './session-core'
8
+ import type {
9
+ CameraDeviceInfo,
10
+ CameraOrientation,
11
+ CameraPermissionKind,
12
+ CameraPermissionStatus,
13
+ CameraSessionCapabilities,
14
+ CameraSessionConfig,
15
+ CaptureIssue,
16
+ EffectiveCaptureConfig,
17
+ MovieOutputHandle,
18
+ } from './types'
19
+
20
+ declare const NSFileManager: any
21
+
22
+ interface CameraHost {
23
+ attachPreview(view: object): string | null
24
+ detachPreview(): void
25
+ installObserver(emit: (message: string) => void): void
26
+ permissionStatus(kind: string): string
27
+ requestPermissionReply(kind: string, reply: (status: string) => void): void
28
+ devices(): string
29
+ audioDeviceAvailable(): boolean
30
+ audioInputAttached(): boolean
31
+ activeDeviceId(): string | null
32
+ configure(options: { cameraId?: string; audio?: boolean; rotationAngle?: number }): string | null
33
+ /** Optional so a host built before the metering leaf shipped degrades
34
+ * to a no-op instead of a selector error. */
35
+ focusAtPoint?(point: { x: number; y: number }): boolean
36
+ supportsRotationAngles(): boolean
37
+ movieDirectory(): string | null
38
+ startRecording(path: string, maximumDurationMs: number): string | null
39
+ stopRecording(): void
40
+ recording(): boolean
41
+ recordedMs(): number
42
+ previewSize(): string
43
+ dispose(): void
44
+ }
45
+
46
+ declare const XplatCameraHost: {
47
+ alloc(): { init(): CameraHost }
48
+ }
49
+
50
+ type HostEvent =
51
+ | { type: 'recordingStarted'; fileUrl: string; audio: boolean }
52
+ | {
53
+ type: 'recordingFinished'
54
+ fileUrl: string
55
+ succeeded: boolean
56
+ limitReached: boolean
57
+ clip?: {
58
+ fileUrl: string
59
+ durationMs: number
60
+ width: number
61
+ height: number
62
+ rotationAngle: number
63
+ fileSize: number
64
+ hasAudio: boolean
65
+ videoCodec?: string
66
+ }
67
+ error?: { domain: string; code: number; message: string }
68
+ }
69
+ | { type: 'interrupted'; cause: string }
70
+ | { type: 'interruptionEnded' }
71
+ | { type: 'availability'; available: boolean }
72
+ | { type: 'previewReady'; ready: boolean }
73
+
74
+ const orientationToRotationAngle = (orientation: CameraOrientation): number | undefined =>
75
+ orientation === 'portrait'
76
+ ? 90
77
+ : orientation === 'portraitUpsideDown'
78
+ ? 270
79
+ : orientation === 'landscapeLeft'
80
+ ? 180
81
+ : orientation === 'landscapeRight'
82
+ ? 0
83
+ : undefined
84
+
85
+ const orientationFromRotationAngle = (angle: number): CameraOrientation =>
86
+ angle === 90
87
+ ? 'portrait'
88
+ : angle === 270
89
+ ? 'portraitUpsideDown'
90
+ : angle === 180
91
+ ? 'landscapeLeft'
92
+ : angle === 0
93
+ ? 'landscapeRight'
94
+ : 'unspecified'
95
+
96
+ const usageKey = (kind: CameraPermissionKind) =>
97
+ kind === 'camera' ? 'NSCameraUsageDescription' : 'NSMicrophoneUsageDescription'
98
+
99
+ /** AppKit adapter: one XplatCameraHost (AVCaptureSession) shared between the
100
+ * attached preview and a single AVCaptureMovieFileOutput capture attempt.
101
+ * The Swift leaf owns the session, inputs, preview layer, and recording
102
+ * delegate; this adapter owns admission, semantics, and contract mapping. */
103
+ export function createSessionBackend(config: CameraSessionConfig): CameraSessionBackend {
104
+ const host = XplatCameraHost.alloc().init()
105
+ let currentConfig = { ...config }
106
+ let previewReadyFlag = false
107
+ let observer: BackendObserver | undefined
108
+ let captureSink: BackendCaptureSink | undefined
109
+ let captureRequest: BackendCaptureRequest | undefined
110
+ let captureFilePath = ''
111
+ let released = false
112
+
113
+ const emitIssue = (
114
+ kind: CaptureIssue['kind'],
115
+ operation: string,
116
+ message: string,
117
+ cause?: unknown,
118
+ ): CaptureIssue => ({ kind, operation, message, cause })
119
+
120
+ const error = (kind: CaptureIssue['kind'], operation: string, message: string, cause?: unknown) =>
121
+ new CameraCaptureError({ kind, operation, message, cause })
122
+
123
+ const devices = (): CameraDeviceInfo[] => {
124
+ try {
125
+ return (
126
+ JSON.parse(host.devices()) as {
127
+ id: string
128
+ name: string
129
+ facing?: 'front' | 'back' | null
130
+ }[]
131
+ ).map((entry) => ({
132
+ id: entry.id,
133
+ label: entry.name,
134
+ facing: entry.facing ?? undefined,
135
+ }))
136
+ } catch {
137
+ return []
138
+ }
139
+ }
140
+
141
+ const previewSize = (): { width: number; height: number } | undefined => {
142
+ try {
143
+ const size = JSON.parse(host.previewSize())
144
+ return size.width > 0 ? { width: size.width, height: size.height } : undefined
145
+ } catch {
146
+ return undefined
147
+ }
148
+ }
149
+
150
+ /** Point-of-interest metering support on the selected device, read
151
+ * from the same device list `devices()` reports. */
152
+ const pointOfInterestSupport = (): { focus: boolean; exposure: boolean } => {
153
+ try {
154
+ const list = JSON.parse(host.devices()) as {
155
+ id: string
156
+ focusPointOfInterest?: boolean
157
+ exposurePointOfInterest?: boolean
158
+ }[]
159
+
160
+ const selected = selectedCameraId()
161
+ const device = list.find((entry) => entry.id === selected) ?? list[0]
162
+ return {
163
+ focus: device?.focusPointOfInterest === true,
164
+ exposure: device?.exposurePointOfInterest === true,
165
+ }
166
+ } catch {
167
+ return { focus: false, exposure: false }
168
+ }
169
+ }
170
+
171
+ const selectedCameraId = (config: CameraSessionConfig = currentConfig): string | undefined => {
172
+ const selection = config.camera
173
+ if (selection && typeof selection === 'object' && 'deviceId' in selection) {
174
+ return selection.deviceId
175
+ }
176
+
177
+ // 'default'/'front'/'back' resolve to the first available device —
178
+ // macOS cameras carry no front/back facing semantics.
179
+ return devices()[0]?.id
180
+ }
181
+
182
+ host.installObserver((message) => {
183
+ let event: HostEvent
184
+ try {
185
+ event = JSON.parse(message)
186
+ } catch {
187
+ return
188
+ }
189
+
190
+ switch (event.type) {
191
+ case 'recordingStarted': {
192
+ if (!sameFile(event.fileUrl, captureFilePath) || !captureSink || !captureRequest) {
193
+ return
194
+ }
195
+
196
+ const size = previewSize()
197
+ const configuration: EffectiveCaptureConfig = {
198
+ cameraId: host.activeDeviceId() ?? selectedCameraId() ?? '',
199
+ audio: event.audio,
200
+ width: size?.width ?? 1280,
201
+ height: size?.height ?? 720,
202
+ frameRate: 30,
203
+ orientation: captureRequest.orientation ?? 'unspecified',
204
+ mimeType: 'video/quicktime',
205
+ container: 'mov',
206
+ }
207
+
208
+ captureSink.started(configuration)
209
+ return
210
+ }
211
+ case 'recordingFinished': {
212
+ if (!sameFile(event.fileUrl, captureFilePath) || !captureSink || !captureRequest) {
213
+ return
214
+ }
215
+
216
+ const sink = captureSink
217
+ const request = captureRequest
218
+ captureSink = undefined
219
+ captureRequest = undefined
220
+
221
+ if (!event.succeeded || !event.clip) {
222
+ sink.settled({
223
+ kind: 'failed',
224
+ stage: 'capture',
225
+ issue: emitIssue(
226
+ 'captureFailed',
227
+ 'finalize',
228
+ event.error?.message ?? 'Movie recording did not complete',
229
+ ),
230
+ })
231
+
232
+ return
233
+ }
234
+
235
+ if (!(event.clip.durationMs > 0) || !(event.clip.width > 0) || !(event.clip.height > 0)) {
236
+ sink.settled({
237
+ kind: 'failed',
238
+ stage: 'capture',
239
+ issue: emitIssue(
240
+ 'captureFailed',
241
+ 'finalize',
242
+ 'The finalized movie has no usable video media',
243
+ ),
244
+ })
245
+
246
+ return
247
+ }
248
+
249
+ if (request.audio && !event.clip.hasAudio) {
250
+ sink.settled({
251
+ kind: 'failed',
252
+ stage: 'capture',
253
+ issue: emitIssue(
254
+ 'captureFailed',
255
+ 'finalize',
256
+ 'Requested audio track is missing from the finalized movie',
257
+ ),
258
+ })
259
+
260
+ return
261
+ }
262
+
263
+ sink.settled({
264
+ kind: 'clip',
265
+ output: {
266
+ kind: 'nativeFile',
267
+ resourceId: event.fileUrl,
268
+ fileUrl: event.fileUrl,
269
+ },
270
+ mimeType: 'video/quicktime',
271
+ durationMs: event.clip.durationMs,
272
+ width: event.clip.width,
273
+ height: event.clip.height,
274
+ orientation:
275
+ request.orientation ?? orientationFromRotationAngle(event.clip.rotationAngle),
276
+ hasAudio: request.audio && event.clip.hasAudio,
277
+ limitReached: event.limitReached,
278
+ })
279
+
280
+ return
281
+ }
282
+ case 'interrupted':
283
+ observer?.interruption(
284
+ 'began',
285
+ (event.cause as Parameters<BackendObserver['interruption']>[1]) ?? 'unknown',
286
+ )
287
+
288
+ observer?.previewReady(previewReadyFlag)
289
+ return
290
+ case 'interruptionEnded':
291
+ observer?.interruption('ended')
292
+ observer?.previewReady(previewReadyFlag)
293
+ return
294
+ case 'availability':
295
+ observer?.availability(event.available)
296
+ return
297
+ case 'previewReady':
298
+ previewReadyFlag = event.ready
299
+ observer?.previewReady(previewReadyFlag)
300
+ return
301
+ }
302
+ })
303
+
304
+ const checkPermission = async (kind: CameraPermissionKind): Promise<CameraPermissionStatus> => {
305
+ const status = host.permissionStatus(kind)
306
+ return (status === 'undeclared' ? 'unknown' : status) as CameraPermissionStatus
307
+ }
308
+
309
+ const requestPermission = async (kind: CameraPermissionKind): Promise<CameraPermissionStatus> => {
310
+ if (host.permissionStatus(kind) === 'undeclared') {
311
+ throw error(
312
+ 'configurationMissing',
313
+ 'requestPermission',
314
+ `Missing ${usageKey(kind)} in the app Info.plist`,
315
+ )
316
+ }
317
+
318
+ const status = await new Promise<string>((resolve) =>
319
+ host.requestPermissionReply(kind, resolve),
320
+ )
321
+
322
+ return (status === 'undeclared' ? 'unknown' : status) as CameraPermissionStatus
323
+ }
324
+
325
+ const recordingsDirectory = (): string => {
326
+ const root = host.movieDirectory()
327
+ if (!root) {
328
+ throw error(
329
+ 'destinationUnavailable',
330
+ 'startRecording',
331
+ 'The app has no writable private camera directory',
332
+ )
333
+ }
334
+
335
+ return decodeURIComponent(root.replace(/^file:\/\//, '').replace(/\/$/, ''))
336
+ }
337
+
338
+ // Durable output must stay app-private: destinations resolve inside the
339
+ // app's own Application Support root — the recordings directory's parent.
340
+ const appPrivateRoot = () =>
341
+ recordingsDirectory().slice(0, recordingsDirectory().lastIndexOf('/'))
342
+
343
+ const pathExists = (path: string): boolean =>
344
+ Boolean(NSFileManager.defaultManager.fileExistsAtPath(path))
345
+
346
+ // Correlate capture events by path, not serialized URL — Foundation's
347
+ // URL(fileURLWithPath:).absoluteString escapes differently than
348
+ // encodeURIComponent, so URL equality drops real start/finish events.
349
+ const sameFile = (fileUrl: string | undefined, filePath: string): boolean => {
350
+ if (!fileUrl || !filePath) {
351
+ return false
352
+ }
353
+
354
+ const raw = fileUrl.startsWith('file://') ? fileUrl.slice('file://'.length) : fileUrl
355
+ try {
356
+ return decodeURIComponent(raw) === filePath
357
+ } catch {
358
+ return raw === filePath
359
+ }
360
+ }
361
+
362
+ return {
363
+ platform: 'macos',
364
+ get supported() {
365
+ return !released
366
+ },
367
+ checkPermission,
368
+ requestPermission,
369
+ getCapabilities: async (): Promise<CameraSessionCapabilities> => {
370
+ const list = devices()
371
+ const available = list.length > 0
372
+ const size = previewSize()
373
+ const pointOfInterest = pointOfInterestSupport()
374
+ return {
375
+ supported: available,
376
+ reason: available ? undefined : 'No camera device is available on this host',
377
+ available,
378
+ cameras: list,
379
+ profiles: [
380
+ {
381
+ profile: 'standard',
382
+ width: size?.width ?? 1280,
383
+ height: size?.height ?? 720,
384
+ frameRate: 30,
385
+ },
386
+ ],
387
+ audio: host.audioDeviceAvailable(),
388
+ focusPointOfInterest: pointOfInterest.focus,
389
+ exposurePointOfInterest: pointOfInterest.exposure,
390
+ // AVCaptureConnection rotation angles exist on macOS 14+;
391
+ // earlier hosts cannot lock a take's orientation.
392
+ orientations: host.supportsRotationAngles()
393
+ ? ['landscapeLeft', 'landscapeRight', 'portrait', 'portraitUpsideDown']
394
+ : [],
395
+ durationLimit: 'native',
396
+ elapsedTime: 'nativeMedia',
397
+ output: {
398
+ mimeType: 'video/quicktime',
399
+ container: 'mov',
400
+ storage: 'appPrivateFile',
401
+ destinationFileUrl: true,
402
+ },
403
+ }
404
+ },
405
+ previewReady: () => previewReadyFlag,
406
+ attachPreview: (target, listener?: BackendPreviewListener) => {
407
+ if (released) {
408
+ throw error('unavailable', 'attachPreview', 'CameraSession is disposed')
409
+ }
410
+
411
+ if (host.permissionStatus('camera') !== 'granted') {
412
+ throw error(
413
+ 'permissionDenied',
414
+ 'attachPreview',
415
+ 'Camera preview requires an explicit granted camera permission',
416
+ )
417
+ }
418
+
419
+ if (!devices().length) {
420
+ throw error('unavailable', 'attachPreview', 'No camera device is available on this host')
421
+ }
422
+
423
+ const configured = host.configure({
424
+ cameraId: selectedCameraId(),
425
+ audio: currentConfig.audio ?? false,
426
+ })
427
+
428
+ if (configured) {
429
+ throw error(
430
+ configured === 'busy'
431
+ ? 'busy'
432
+ : configured === 'permissionDenied'
433
+ ? 'permissionDenied'
434
+ : 'unavailable',
435
+ 'attachPreview',
436
+ `Failed to configure the capture session: ${configured}`,
437
+ )
438
+ }
439
+
440
+ const failure = host.attachPreview(target as object)
441
+ if (failure) {
442
+ throw error(
443
+ failure === 'permissionDenied' ? 'permissionDenied' : 'unavailable',
444
+ 'attachPreview',
445
+ `Failed to start camera preview: ${failure}`,
446
+ )
447
+ }
448
+
449
+ listener?.onReady?.()
450
+ return () => {
451
+ previewReadyFlag = false
452
+ observer?.previewReady(false)
453
+ host.detachPreview()
454
+ }
455
+ },
456
+ configure: (next) => {
457
+ const configured = host.configure({
458
+ cameraId: selectedCameraId(next),
459
+ audio: next.audio ?? false,
460
+ })
461
+
462
+ if (configured) {
463
+ throw error(
464
+ configured === 'busy'
465
+ ? 'busy'
466
+ : configured === 'permissionDenied'
467
+ ? 'permissionDenied'
468
+ : configured === 'unsupportedConfiguration'
469
+ ? 'unsupportedConfiguration'
470
+ : 'unavailable',
471
+ 'configure',
472
+ `Failed to configure the capture session: ${configured}`,
473
+ )
474
+ }
475
+
476
+ currentConfig = { ...next }
477
+ },
478
+ focusAtPoint: (point) => {
479
+ try {
480
+ host.focusAtPoint?.({ x: point.x, y: point.y })
481
+ } catch {
482
+ // A missed metering pass must not fault the session.
483
+ }
484
+ },
485
+ startCapture: (request, sink) => {
486
+ if (host.permissionStatus('camera') === 'undeclared') {
487
+ sink.settled({
488
+ kind: 'failed',
489
+ stage: 'preparation',
490
+ issue: emitIssue(
491
+ 'configurationMissing',
492
+ 'startRecording',
493
+ "Missing NSCameraUsageDescription in the host app's Info.plist",
494
+ ),
495
+ })
496
+
497
+ return
498
+ }
499
+
500
+ if (request.audio) {
501
+ if (host.permissionStatus('microphone') === 'undeclared') {
502
+ sink.settled({
503
+ kind: 'failed',
504
+ stage: 'preparation',
505
+ issue: emitIssue(
506
+ 'configurationMissing',
507
+ 'startRecording',
508
+ "Missing NSMicrophoneUsageDescription in the host app's Info.plist",
509
+ ),
510
+ })
511
+
512
+ return
513
+ }
514
+
515
+ const mic = host.permissionStatus('microphone')
516
+ if (mic !== 'granted') {
517
+ sink.settled({
518
+ kind: 'failed',
519
+ stage: 'preparation',
520
+ issue: emitIssue(
521
+ mic === 'restricted' ? 'permissionRestricted' : 'permissionDenied',
522
+ 'startRecording',
523
+ `Microphone permission is ${mic}`,
524
+ ),
525
+ })
526
+
527
+ return
528
+ }
529
+
530
+ if (!host.audioDeviceAvailable()) {
531
+ sink.settled({
532
+ kind: 'failed',
533
+ stage: 'preparation',
534
+ issue: emitIssue('unavailable', 'startRecording', 'No audio input device is available'),
535
+ })
536
+
537
+ return
538
+ }
539
+ }
540
+
541
+ const rotationAngle =
542
+ request.orientation !== undefined
543
+ ? orientationToRotationAngle(request.orientation)
544
+ : undefined
545
+
546
+ if (request.orientation !== undefined && rotationAngle === undefined) {
547
+ sink.settled({
548
+ kind: 'failed',
549
+ stage: 'preparation',
550
+ issue: emitIssue(
551
+ 'unsupportedConfiguration',
552
+ 'startRecording',
553
+ `Orientation '${request.orientation}' is not supported by this connection`,
554
+ ),
555
+ })
556
+
557
+ return
558
+ }
559
+
560
+ const configured = host.configure({
561
+ cameraId: selectedCameraId(),
562
+ audio: request.audio || (currentConfig.audio ?? false),
563
+ rotationAngle,
564
+ })
565
+
566
+ if (configured) {
567
+ sink.settled({
568
+ kind: 'failed',
569
+ stage: 'preparation',
570
+ issue: emitIssue(
571
+ configured === 'unsupportedConfiguration' ? 'unsupportedConfiguration' : 'unavailable',
572
+ 'startRecording',
573
+ configured === 'unsupportedConfiguration'
574
+ ? `Orientation '${request.orientation}' is not supported by this connection`
575
+ : 'The requested capture configuration is unavailable',
576
+ ),
577
+ })
578
+
579
+ return
580
+ }
581
+
582
+ let filePath: string
583
+ if (request.destinationPath) {
584
+ // Canonicalize before the prefix check — a '..' segment keeps
585
+ // the root prefix on a raw string while resolving outside it.
586
+ const destination = canonicalizePath(request.destinationPath)
587
+ const inside = destination.startsWith(`${appPrivateRoot()}/`)
588
+ const directory = destination.slice(0, destination.lastIndexOf('/'))
589
+
590
+ const directoryExists = inside && pathExists(directory)
591
+ const fileExists = inside && pathExists(destination)
592
+ if (!inside || !directoryExists || fileExists) {
593
+ sink.settled({
594
+ kind: 'failed',
595
+ stage: 'preparation',
596
+ issue: emitIssue(
597
+ 'destinationUnavailable',
598
+ 'startRecording',
599
+ !inside
600
+ ? 'destinationFileUrl must resolve inside the app-private recordings directory'
601
+ : fileExists
602
+ ? 'destinationFileUrl already exists'
603
+ : 'destinationFileUrl directory does not exist',
604
+ ),
605
+ })
606
+
607
+ return
608
+ }
609
+
610
+ if (!/\.mov$/i.test(destination)) {
611
+ sink.settled({
612
+ kind: 'failed',
613
+ stage: 'preparation',
614
+ issue: emitIssue(
615
+ 'destinationUnavailable',
616
+ 'startRecording',
617
+ 'destinationFileUrl must use a .mov file name',
618
+ ),
619
+ })
620
+
621
+ return
622
+ }
623
+
624
+ filePath = destination
625
+ } else {
626
+ try {
627
+ filePath = `${recordingsDirectory()}/clip-${Date.now()}-${Math.random()
628
+ .toString(36)
629
+ .slice(2, 8)}.mov`
630
+ } catch (cause) {
631
+ sink.settled({
632
+ kind: 'failed',
633
+ stage: 'preparation',
634
+ issue: emitIssue(
635
+ 'destinationUnavailable',
636
+ 'startRecording',
637
+ cause instanceof Error ? cause.message : String(cause),
638
+ cause,
639
+ ),
640
+ })
641
+
642
+ return
643
+ }
644
+ }
645
+
646
+ captureFilePath = filePath
647
+
648
+ captureSink = sink
649
+ captureRequest = request
650
+ const failure = host.startRecording(filePath, request.maximumDurationMs ?? 0)
651
+ if (failure) {
652
+ captureSink = undefined
653
+ captureRequest = undefined
654
+ captureFilePath = ''
655
+ sink.settled({
656
+ kind: 'failed',
657
+ stage: 'preparation',
658
+ issue: emitIssue(
659
+ 'unavailable',
660
+ 'startRecording',
661
+ `Failed to start movie capture: ${failure}`,
662
+ ),
663
+ })
664
+ }
665
+ },
666
+ stopCapture: () => {
667
+ host.stopRecording()
668
+ },
669
+ elapsedMs: () => host.recordedMs(),
670
+ openOutput: async (output) => {
671
+ if (output.kind !== 'nativeFile' || !output.fileUrl) {
672
+ throw error(
673
+ 'invalidArgument',
674
+ 'openOutput',
675
+ 'Only file-backed output can be opened on macOS',
676
+ )
677
+ }
678
+
679
+ const filePath = decodeURIComponent(output.fileUrl.slice('file://'.length))
680
+ if (!pathExists(filePath)) {
681
+ throw error('unavailable', 'openOutput', `Movie file is missing at ${output.fileUrl}`)
682
+ }
683
+
684
+ const handle: MovieOutputHandle = {
685
+ url: output.fileUrl,
686
+ fileUrl: output.fileUrl,
687
+ release: () => {},
688
+ }
689
+
690
+ return handle
691
+ },
692
+ release: async () => {
693
+ released = true
694
+ previewReadyFlag = false
695
+ captureSink = undefined
696
+ captureRequest = undefined
697
+ host.dispose()
698
+ },
699
+ onPreviewDetached: () => {
700
+ // The view went away while a take settles; the Swift side keeps the
701
+ // session alive until recordingFinished arrives.
702
+ },
703
+ setObserver: (next) => {
704
+ observer = next
705
+ },
706
+ }
707
+ }