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