@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,659 @@
1
+ import { CameraCaptureError } from './types'
2
+ import type {
3
+ CameraInterruptionReason,
4
+ CameraPermissionKind,
5
+ CameraPermissionStatus,
6
+ CameraSession,
7
+ CameraSessionCapabilities,
8
+ CameraSessionConfig,
9
+ CameraSessionEvent,
10
+ CameraSessionSnapshot,
11
+ CameraSelection,
12
+ CaptureIssue,
13
+ EffectiveCaptureConfig,
14
+ MovieEndReason,
15
+ MovieOutcome,
16
+ MovieOutput,
17
+ MovieOutputHandle,
18
+ MovieTake,
19
+ StartRecordingOptions,
20
+ } from './types'
21
+
22
+ /** Result an adapter reports when a capture attempt settles. Media
23
+ * metadata is already read from the finalized movie by the adapter. */
24
+ export type BackendSettle =
25
+ | {
26
+ kind: 'clip'
27
+ output: MovieOutput
28
+ mimeType: string
29
+ durationMs: number
30
+ width: number
31
+ height: number
32
+ orientation: EffectiveCaptureConfig['orientation']
33
+ hasAudio: boolean
34
+ /** Native duration-limit enforcement ended the take. */
35
+ limitReached?: boolean
36
+ /** Adapter-driven endings when no earlier app/lifecycle cause exists. */
37
+ endReason?: MovieEndReason
38
+ warning?: CaptureIssue
39
+ }
40
+ | {
41
+ kind: 'failed'
42
+ stage: 'preparation' | 'capture' | 'finalization' | 'storage'
43
+ issue: CaptureIssue
44
+ }
45
+
46
+ /** Per-attempt callbacks handed to `startCapture`. */
47
+ export interface BackendCaptureSink {
48
+ /** Native capture actually began; exactly once per settled attempt. */
49
+ started(config: EffectiveCaptureConfig): void
50
+ /** Capture has ended; metadata verification and storage still own the attempt. */
51
+ finishing(reason?: MovieEndReason): void
52
+ /** Terminal result; exactly once per admitted attempt. */
53
+ settled(result: BackendSettle): void
54
+ }
55
+
56
+ export interface BackendCaptureRequest {
57
+ audio: boolean
58
+ orientation: EffectiveCaptureConfig['orientation'] | undefined
59
+ maximumDurationMs: number | undefined
60
+ /** Resolved writable path for native adapters. */
61
+ destinationPath: string | undefined
62
+ }
63
+
64
+ /** Adapter-owned preview attach. `listener` reports preview lifecycle;
65
+ * returns the detach function. */
66
+ export interface BackendPreviewListener {
67
+ onReady?: () => void
68
+ onError?: (issue: CaptureIssue) => void
69
+ }
70
+
71
+ /** Platform adapter surface driven by the shared session core. Adapters
72
+ * own permission policy, capability discovery, capture, and local output
73
+ * access; the core owns states, ordering, causes, and outcomes. */
74
+ export interface CameraSessionBackend {
75
+ readonly platform: string
76
+ /** False when the adapter cannot capture on this host; the core
77
+ * rejects `startRecording` with `unsupportedPlatform`. */
78
+ readonly supported: boolean
79
+ checkPermission(kind: CameraPermissionKind): Promise<CameraPermissionStatus>
80
+ requestPermission(kind: CameraPermissionKind): Promise<CameraPermissionStatus>
81
+ getCapabilities(): Promise<CameraSessionCapabilities>
82
+ /** An active, ready preview is attached and the camera source is
83
+ * running — the admission requirement for `startRecording`. */
84
+ previewReady(): boolean
85
+ /** Attach the shared-source preview to a platform view. */
86
+ attachPreview(host: unknown, listener?: BackendPreviewListener): () => void
87
+ /** Replace configuration while idle; throws CameraCaptureError. */
88
+ configure?(config: CameraSessionConfig): void
89
+ /** Synchronous platform-specific admission checks; creates no attempt on failure. */
90
+ validateStart?(options: StartRecordingOptions): void
91
+ /** Begin capture for an admitted attempt. Async failures settle via
92
+ * `sink.settled`; a synchronous throw settles as a preparation failure. */
93
+ startCapture(request: BackendCaptureRequest, sink: BackendCaptureSink): void
94
+ /** Request capture stop; settlement still arrives via the sink. */
95
+ stopCapture(): void
96
+ /** Elapsed capture time in ms for the in-flight attempt. */
97
+ elapsedMs(): number
98
+ openOutput(output: MovieOutput): Promise<MovieOutputHandle>
99
+ /** Release camera ownership. The core calls this only after any
100
+ * in-flight attempt has settled. */
101
+ release(): Promise<void>
102
+ /** Reattach/reconfigure bookkeeping when the preview view is detached
103
+ * while an attempt is settling — the adapter keeps hardware until
104
+ * settlement, then stops. */
105
+ onPreviewDetached?(): void
106
+ /** Lifecycle observations the adapter emits at any time. */
107
+ setObserver(observer: BackendObserver | undefined): void
108
+ }
109
+
110
+ export interface BackendObserver {
111
+ interruption(phase: 'began' | 'ended', reason?: CameraInterruptionReason): void
112
+ availability(available: boolean): void
113
+ /** Preview became ready/unready (session running state changed). */
114
+ previewReady(ready: boolean): void
115
+ }
116
+
117
+ let attemptCounter = 0
118
+
119
+ const issue = (
120
+ kind: CaptureIssue['kind'],
121
+ operation: string,
122
+ message: string,
123
+ extra?: Partial<CaptureIssue>,
124
+ ): CameraCaptureError => new CameraCaptureError({ kind, operation, message, ...extra })
125
+
126
+ /** The concrete session object wrapping a platform backend. Internal to
127
+ * the package; `CameraSession` is the public shape. */
128
+ export class SharedCameraSession implements CameraSession {
129
+ private backend: CameraSessionBackend
130
+ private config: CameraSessionConfig
131
+ private listeners = new Set<(event: CameraSessionEvent) => void>()
132
+ private state: CameraSessionSnapshot['state'] = 'idle'
133
+ private permission: Record<CameraPermissionKind, CameraPermissionStatus> = {
134
+ camera: 'unknown',
135
+ microphone: 'unknown',
136
+ }
137
+ private cameraAvailable = true
138
+ private previewAttached = false
139
+ private interrupted = false
140
+ private disposeRequested = false
141
+ private disposed = false
142
+ private lastCapabilities: CameraSessionCapabilities | undefined
143
+ private requestChains = new Map<CameraPermissionKind, Promise<CameraPermissionStatus>>()
144
+
145
+ private attempt:
146
+ | {
147
+ id: string
148
+ state: 'starting' | 'recording' | 'finalizing' | 'settled'
149
+ stopRequested: boolean
150
+ cause?: MovieEndReason
151
+ startedAt?: number
152
+ configuration?: EffectiveCaptureConfig
153
+ resolve: (outcome: MovieOutcome) => void
154
+ completion: Promise<MovieOutcome>
155
+ limitTimer?: ReturnType<typeof setTimeout>
156
+ }
157
+ | undefined
158
+
159
+ constructor(backend: CameraSessionBackend, config: CameraSessionConfig) {
160
+ this.backend = backend
161
+ this.config = { ...config }
162
+ backend.setObserver({
163
+ interruption: (phase, reason) => this.handleInterruption(phase, reason),
164
+ availability: (available) => this.handleAvailability(available),
165
+ previewReady: () => {
166
+ this.emit({ type: 'stateChanged', snapshot: this.snapshot() })
167
+ },
168
+ })
169
+ }
170
+
171
+ /** Package-internal: attach a preview host to this session. */
172
+ attach(host: unknown, listener?: BackendPreviewListener): () => void {
173
+ if (this.disposed || this.disposeRequested) {
174
+ throw issue('invalidState', 'attachPreview', 'CameraSession is disposed')
175
+ }
176
+
177
+ if (this.previewAttached) {
178
+ throw issue('invalidState', 'attachPreview', 'CameraSession already has a preview')
179
+ }
180
+
181
+ const detach = this.backend.attachPreview(host, {
182
+ onReady: listener?.onReady,
183
+ onError: (captureIssue) => {
184
+ listener?.onError?.(captureIssue)
185
+ },
186
+ })
187
+
188
+ this.previewAttached = true
189
+ this.emit({ type: 'stateChanged', snapshot: this.snapshot() })
190
+ return () => {
191
+ this.detachInternal(detach)
192
+ }
193
+ }
194
+
195
+ get attached(): boolean {
196
+ return this.previewAttached
197
+ }
198
+
199
+ private detachInternal(detach: () => void) {
200
+ if (!this.previewAttached) {
201
+ return
202
+ }
203
+
204
+ this.previewAttached = false
205
+ const attempt = this.attempt
206
+ if (attempt && (attempt.state === 'starting' || attempt.state === 'recording')) {
207
+ this.requestStop('previewReleased')
208
+ }
209
+
210
+ detach()
211
+ this.backend.onPreviewDetached?.()
212
+ this.emit({ type: 'stateChanged', snapshot: this.snapshot() })
213
+ }
214
+
215
+ private emit(event: CameraSessionEvent) {
216
+ for (const listener of this.listeners) {
217
+ try {
218
+ listener(event)
219
+ } catch {
220
+ // Listener exceptions must not change capture behavior.
221
+ }
222
+ }
223
+ }
224
+
225
+ private setState(state: CameraSessionSnapshot['state']) {
226
+ if (this.state === state) {
227
+ return
228
+ }
229
+
230
+ this.state = state
231
+ this.emit({ type: 'stateChanged', snapshot: this.snapshot() })
232
+ }
233
+
234
+ snapshot(): CameraSessionSnapshot {
235
+ const attempt = this.attempt
236
+ return {
237
+ state: this.state,
238
+ permission: { ...this.permission },
239
+ cameraAvailable: this.cameraAvailable,
240
+ previewAttached: this.previewAttached,
241
+ interrupted: this.interrupted,
242
+ attempt:
243
+ attempt && attempt.state !== 'settled'
244
+ ? {
245
+ id: attempt.id,
246
+ state: attempt.state,
247
+ elapsedMs: this.backend.elapsedMs(),
248
+ configuration: attempt.configuration,
249
+ }
250
+ : undefined,
251
+ }
252
+ }
253
+
254
+ subscribe(listener: (event: CameraSessionEvent) => void): () => void {
255
+ this.listeners.add(listener)
256
+ return () => {
257
+ this.listeners.delete(listener)
258
+ }
259
+ }
260
+
261
+ async permissions() {
262
+ const [camera, microphone] = await Promise.all([
263
+ this.backend.checkPermission('camera'),
264
+ this.backend.checkPermission('microphone'),
265
+ ])
266
+
267
+ this.permission = { camera, microphone }
268
+ this.emit({ type: 'stateChanged', snapshot: this.snapshot() })
269
+ return { ...this.permission }
270
+ }
271
+
272
+ requestPermission(kind: CameraPermissionKind): Promise<CameraPermissionStatus> {
273
+ const chain = (
274
+ this.requestChains.get(kind) ?? Promise.resolve('unknown' as CameraPermissionStatus)
275
+ ).then(async () => {
276
+ if (this.disposed) {
277
+ return this.permission[kind]
278
+ }
279
+
280
+ const status = await this.backend.requestPermission(kind)
281
+ this.permission = { ...this.permission, [kind]: status }
282
+ this.emit({ type: 'stateChanged', snapshot: this.snapshot() })
283
+ return status
284
+ })
285
+
286
+ this.requestChains.set(kind, chain)
287
+ return chain
288
+ }
289
+
290
+ async capabilities(): Promise<CameraSessionCapabilities> {
291
+ const capabilities = await this.backend.getCapabilities()
292
+ this.lastCapabilities = capabilities
293
+ return capabilities
294
+ }
295
+
296
+ async configure(next: Partial<CameraSessionConfig>): Promise<void> {
297
+ if (this.disposed || this.disposeRequested) {
298
+ throw issue('invalidState', 'configure', 'CameraSession is disposed')
299
+ }
300
+
301
+ if (this.state !== 'idle') {
302
+ throw issue('invalidState', 'configure', `CameraSession cannot configure while ${this.state}`)
303
+ }
304
+
305
+ const merged: CameraSessionConfig = { ...this.config, ...next }
306
+ const capabilities = await this.capabilities()
307
+ this.validateCameraSelection(merged.camera, capabilities)
308
+ this.config = merged
309
+ this.backend.configure?.(merged)
310
+ }
311
+
312
+ private validateCameraSelection(
313
+ camera: CameraSelection | undefined,
314
+ capabilities: CameraSessionCapabilities,
315
+ ) {
316
+ if (camera && typeof camera === 'object' && 'deviceId' in camera) {
317
+ const known = capabilities.cameras.some((entry) => entry.id === camera.deviceId)
318
+ if (!known) {
319
+ throw issue(
320
+ 'unsupportedConfiguration',
321
+ 'configure',
322
+ `Camera device '${camera.deviceId}' is not available`,
323
+ )
324
+ }
325
+ } else if (camera === 'front' || camera === 'back') {
326
+ const known = capabilities.cameras.some((entry) => entry.facing === camera)
327
+ if (!known && capabilities.cameras.length > 0) {
328
+ throw issue(
329
+ 'unsupportedConfiguration',
330
+ 'configure',
331
+ `No ${camera}-facing camera is available`,
332
+ )
333
+ }
334
+ }
335
+ }
336
+
337
+ private requestStop(cause: MovieEndReason) {
338
+ const attempt = this.attempt
339
+ if (!attempt || attempt.state === 'finalizing' || attempt.state === 'settled') {
340
+ return
341
+ }
342
+
343
+ attempt.stopRequested = true
344
+ attempt.cause ??= cause
345
+ if (attempt.state === 'recording') {
346
+ this.setState('finalizing')
347
+ attempt.state = 'finalizing'
348
+ try {
349
+ this.backend.stopCapture()
350
+ } catch {
351
+ // A stop failure still resolves through the settle path or a
352
+ // backend timeout; nothing more to do here.
353
+ }
354
+ }
355
+ }
356
+
357
+ private handleInterruption(phase: 'began' | 'ended', reason?: CameraInterruptionReason) {
358
+ this.interrupted = phase === 'began'
359
+ this.emit({ type: 'interruption', phase, reason })
360
+ if (phase === 'began') {
361
+ const cause: MovieEndReason =
362
+ reason === 'background'
363
+ ? 'backgrounded'
364
+ : reason === 'cameraInUse'
365
+ ? 'cameraUnavailable'
366
+ : 'interrupted'
367
+
368
+ this.requestStop(cause)
369
+ }
370
+
371
+ this.emit({ type: 'stateChanged', snapshot: this.snapshot() })
372
+ }
373
+
374
+ private handleAvailability(available: boolean) {
375
+ if (this.cameraAvailable === available) {
376
+ return
377
+ }
378
+
379
+ this.cameraAvailable = available
380
+ this.emit({ type: 'availability', available })
381
+ if (!available) {
382
+ this.requestStop('cameraUnavailable')
383
+ }
384
+
385
+ this.emit({ type: 'stateChanged', snapshot: this.snapshot() })
386
+ }
387
+
388
+ startRecording(options: StartRecordingOptions = {}): MovieTake {
389
+ const operation = 'startRecording'
390
+ if (this.disposed || this.disposeRequested) {
391
+ throw issue('invalidState', operation, 'CameraSession is disposed')
392
+ }
393
+
394
+ if (!this.backend.supported) {
395
+ throw issue(
396
+ 'unsupportedPlatform',
397
+ operation,
398
+ 'Movie recording is unsupported on this platform',
399
+ )
400
+ }
401
+
402
+ if (this.state !== 'idle' || this.attempt) {
403
+ throw issue('busy', operation, `CameraSession is ${this.state}`)
404
+ }
405
+
406
+ if (options.maximumDurationMs !== undefined) {
407
+ const value = options.maximumDurationMs
408
+ if (!Number.isFinite(value) || value <= 0) {
409
+ throw issue(
410
+ 'invalidArgument',
411
+ operation,
412
+ 'maximumDurationMs must be a positive finite number of milliseconds',
413
+ )
414
+ }
415
+ }
416
+
417
+ if (
418
+ options.destinationFileUrl !== undefined &&
419
+ !options.destinationFileUrl.startsWith('file://')
420
+ ) {
421
+ throw issue('invalidArgument', operation, 'destinationFileUrl must be a local file:// URL')
422
+ }
423
+
424
+ if (
425
+ options.orientation !== undefined &&
426
+ options.orientation !== 'unspecified' &&
427
+ this.lastCapabilities &&
428
+ !this.lastCapabilities.orientations.includes(options.orientation)
429
+ ) {
430
+ throw issue(
431
+ 'unsupportedConfiguration',
432
+ 'startRecording',
433
+ `Orientation '${options.orientation}' is not advertised by this camera`,
434
+ )
435
+ }
436
+
437
+ if (this.interrupted || !this.cameraAvailable || !this.backend.previewReady()) {
438
+ throw issue(
439
+ 'unavailable',
440
+ operation,
441
+ this.interrupted
442
+ ? 'Camera session is interrupted'
443
+ : !this.cameraAvailable
444
+ ? 'Camera is unavailable'
445
+ : 'An active, ready preview is required before recording',
446
+ )
447
+ }
448
+
449
+ this.backend.validateStart?.(options)
450
+
451
+ attemptCounter += 1
452
+ const id = `take-${Date.now()}-${attemptCounter}`
453
+ let resolveCompletion!: (outcome: MovieOutcome) => void
454
+ const completion = new Promise<MovieOutcome>((resolve) => {
455
+ resolveCompletion = resolve
456
+ })
457
+
458
+ const attempt: {
459
+ id: string
460
+ state: 'starting' | 'recording' | 'finalizing' | 'settled'
461
+ stopRequested: boolean
462
+ cause?: MovieEndReason
463
+ startedAt?: number
464
+ configuration?: EffectiveCaptureConfig
465
+ resolve: (outcome: MovieOutcome) => void
466
+ completion: Promise<MovieOutcome>
467
+ limitTimer?: ReturnType<typeof setTimeout>
468
+ } = {
469
+ id,
470
+ state: 'starting',
471
+ stopRequested: false,
472
+ resolve: resolveCompletion,
473
+ completion,
474
+ }
475
+
476
+ this.attempt = attempt
477
+ this.setState('starting')
478
+
479
+ const capabilities = this.lastCapabilities
480
+ const sink: BackendCaptureSink = {
481
+ started: (configuration) => {
482
+ const current = this.attempt
483
+ if (!current || current.id !== id || current.state === 'settled') {
484
+ return
485
+ }
486
+
487
+ current.configuration = configuration
488
+ current.startedAt = Date.now()
489
+ if (current.state === 'starting') {
490
+ current.state = 'recording'
491
+ this.setState('recording')
492
+ this.emit({ type: 'started', attemptId: id, configuration })
493
+ if (
494
+ options.maximumDurationMs !== undefined &&
495
+ capabilities?.durationLimit === 'bestEffort'
496
+ ) {
497
+ const limit = options.maximumDurationMs
498
+ const checkLimit = () => {
499
+ if (this.attempt?.id !== id || current.state !== 'recording') {
500
+ return
501
+ }
502
+
503
+ const remaining = limit - this.backend.elapsedMs()
504
+ if (remaining > 0) {
505
+ current.limitTimer = setTimeout(checkLimit, Math.min(remaining, 2_147_483_647))
506
+ } else {
507
+ this.requestStop('maximumDuration')
508
+ }
509
+ }
510
+
511
+ current.limitTimer = setTimeout(checkLimit, Math.min(limit, 2_147_483_647))
512
+ }
513
+
514
+ if (current.stopRequested) {
515
+ this.requestStop(current.cause ?? 'stopped')
516
+ }
517
+ }
518
+ },
519
+ finishing: (reason) => {
520
+ const current = this.attempt
521
+ if (!current || current.id !== id || current.state === 'settled') {
522
+ return
523
+ }
524
+
525
+ current.cause ??= reason
526
+ current.state = 'finalizing'
527
+ this.setState('finalizing')
528
+ },
529
+ settled: (result) => {
530
+ const current = this.attempt
531
+ if (!current || current.id !== id || current.state === 'settled') {
532
+ return
533
+ }
534
+
535
+ if (current.limitTimer) {
536
+ clearTimeout(current.limitTimer)
537
+ }
538
+
539
+ current.state = 'settled'
540
+ const cause = current.cause ?? (result.kind === 'clip' ? result.endReason : undefined)
541
+ const outcome: MovieOutcome =
542
+ result.kind === 'clip'
543
+ ? {
544
+ kind: 'clip',
545
+ clip: {
546
+ output: result.output,
547
+ mimeType: result.mimeType,
548
+ durationMs: result.durationMs,
549
+ width: result.width,
550
+ height: result.height,
551
+ orientation: result.orientation,
552
+ hasAudio: result.hasAudio,
553
+ },
554
+ endReason: cause ?? (result.limitReached ? 'maximumDuration' : 'stopped'),
555
+ partial: cause !== undefined && cause !== 'stopped' && cause !== 'maximumDuration',
556
+ warning: result.warning,
557
+ }
558
+ : {
559
+ kind: 'failed',
560
+ endReason: cause ?? 'captureError',
561
+ stage: result.stage,
562
+ error: new CameraCaptureError(result.issue),
563
+ }
564
+
565
+ this.attempt = undefined
566
+ current.resolve(outcome)
567
+ this.emit({ type: 'finished', attemptId: id, outcome })
568
+ if (this.disposeRequested) {
569
+ void this.finishDispose()
570
+ } else {
571
+ this.setState('idle')
572
+ }
573
+ },
574
+ }
575
+
576
+ const request: BackendCaptureRequest = {
577
+ audio: this.config.audio === true,
578
+ orientation: options.orientation,
579
+ maximumDurationMs: options.maximumDurationMs,
580
+ destinationPath: options.destinationFileUrl?.startsWith('file://')
581
+ ? decodeURIComponent(options.destinationFileUrl.slice('file://'.length))
582
+ : undefined,
583
+ }
584
+
585
+ try {
586
+ this.backend.startCapture(request, sink)
587
+ } catch (cause) {
588
+ sink.settled({
589
+ kind: 'failed',
590
+ stage: 'preparation',
591
+ issue:
592
+ cause instanceof CameraCaptureError
593
+ ? cause
594
+ : {
595
+ kind: 'captureFailed',
596
+ operation: 'startRecording',
597
+ message: cause instanceof Error ? cause.message : String(cause),
598
+ cause,
599
+ },
600
+ })
601
+ }
602
+
603
+ const backend = this.backend
604
+ const take: MovieTake = {
605
+ id,
606
+ get state() {
607
+ return attempt.state
608
+ },
609
+ get elapsedMs() {
610
+ return attempt.state === 'recording' || attempt.state === 'finalizing'
611
+ ? backend.elapsedMs()
612
+ : 0
613
+ },
614
+ completion,
615
+ stop: () => {
616
+ if (this.attempt?.id === id) {
617
+ this.requestStop('stopped')
618
+ }
619
+
620
+ return completion
621
+ },
622
+ }
623
+
624
+ return take
625
+ }
626
+
627
+ openOutput(output: MovieOutput): Promise<MovieOutputHandle> {
628
+ return this.backend.openOutput(output)
629
+ }
630
+
631
+ async dispose(): Promise<void> {
632
+ if (this.disposed) {
633
+ return
634
+ }
635
+
636
+ this.disposeRequested = true
637
+ const attempt = this.attempt
638
+ if (attempt && attempt.state !== 'settled') {
639
+ this.requestStop('disposed')
640
+ await attempt.completion
641
+ }
642
+
643
+ await this.finishDispose()
644
+ }
645
+
646
+ private async finishDispose() {
647
+ if (this.disposed) {
648
+ return
649
+ }
650
+
651
+ this.disposed = true
652
+ this.backend.setObserver(undefined)
653
+ try {
654
+ await this.backend.release()
655
+ } finally {
656
+ this.setState('disposed')
657
+ }
658
+ }
659
+ }
package/src/session.ts ADDED
@@ -0,0 +1,37 @@
1
+ import { SharedCameraSession } from './session-core'
2
+ import type { BackendPreviewListener } from './session-core'
3
+ import { createSessionBackend } from './session-backend'
4
+ import { CameraCaptureError } from './types'
5
+ import type { CameraSession, CameraSessionConfig } from './types'
6
+
7
+ /** Create a shared camera owner: one acquisition shared between a
8
+ * `CameraView` preview (`session` prop) and at most one movie-recording
9
+ * attempt. Creating a session never prompts for permission or acquires
10
+ * hardware — both happen when a session-backed preview activates or a
11
+ * permission request runs.
12
+ *
13
+ * Recording support is delivered per platform; `capabilities()` reports
14
+ * honest `supported`/`available` state on every host. */
15
+ export function createCameraSession(config: CameraSessionConfig = {}): CameraSession {
16
+ return new SharedCameraSession(createSessionBackend(config), config)
17
+ }
18
+
19
+ /** Package-internal: attach `host` as the session's preview surface.
20
+ * Used by `CameraView` when its `session` prop is set; detaching the
21
+ * returned function requests stop on an in-flight attempt and releases
22
+ * camera ownership once finalization settles. */
23
+ export function attachSessionPreview(
24
+ session: CameraSession,
25
+ host: unknown,
26
+ listener?: BackendPreviewListener,
27
+ ): () => void {
28
+ if (!(session instanceof SharedCameraSession)) {
29
+ throw new CameraCaptureError({
30
+ kind: 'invalidArgument',
31
+ operation: 'attachPreview',
32
+ message: 'session was not created by createCameraSession',
33
+ })
34
+ }
35
+
36
+ return session.attach(host, listener)
37
+ }