@octane-xplat/camera 0.0.0-nightly.g2e22171

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