@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,891 @@
1
+ import { CameraCaptureError } from './types'
2
+ import {
3
+ cameraHost,
4
+ commitHostMovie,
5
+ openHostMovie,
6
+ removeHostReservation,
7
+ reserveHostMovie,
8
+ type CameraHost,
9
+ } from './host-movie.web'
10
+
11
+ import { commitMovie, openStoredMovie, removeReservation, reserveMovie } from './movie-storage.web'
12
+ import { inspectMovie } from './movie-metadata.web'
13
+ import type {
14
+ BackendCaptureSink,
15
+ BackendObserver,
16
+ BackendPreviewListener,
17
+ CameraSessionBackend,
18
+ } from './session-core'
19
+
20
+ import type {
21
+ CameraPermissionKind,
22
+ CameraPermissionStatus,
23
+ CameraSessionConfig,
24
+ CaptureIssue,
25
+ EffectiveCaptureConfig,
26
+ MovieClip,
27
+ } from './types'
28
+
29
+ const mimeCandidates = [
30
+ 'video/webm;codecs=vp8,opus',
31
+ 'video/webm;codecs=vp9,opus',
32
+ 'video/mp4;codecs=avc1.424028,mp4a.40.2',
33
+ 'video/mp4',
34
+ ]
35
+
36
+ let owner: object | undefined
37
+
38
+ const error = (kind: CaptureIssue['kind'], operation: string, message: string, cause?: unknown) =>
39
+ new CameraCaptureError({ kind, operation, message, cause })
40
+
41
+ const policyAllows = (kind: CameraPermissionKind) => {
42
+ const policy =
43
+ (
44
+ document as Document & {
45
+ permissionsPolicy?: { allowsFeature(feature: string): boolean }
46
+ featurePolicy?: { allowsFeature(feature: string): boolean }
47
+ }
48
+ ).permissionsPolicy ?? (document as any).featurePolicy
49
+
50
+ return policy?.allowsFeature(kind) !== false
51
+ }
52
+
53
+ export function createSessionBackend(config: CameraSessionConfig): CameraSessionBackend {
54
+ let currentConfig = { ...config }
55
+ let observer: BackendObserver | undefined
56
+ let host: HTMLVideoElement | undefined
57
+ let listener: BackendPreviewListener | undefined
58
+ let stream: MediaStream | undefined
59
+ let ready = false
60
+ let released = false
61
+ let generation = 0
62
+ let persistent = false
63
+ let recorder: MediaRecorder | undefined
64
+ let sink: BackendCaptureSink | undefined
65
+ let startedAt = 0
66
+ let stoppedAt = 0
67
+ let stopTimer: ReturnType<typeof setTimeout> | undefined
68
+ let watchdog: ReturnType<typeof setTimeout> | undefined
69
+ let recordingAudio = false
70
+ let captureFailure: ((cause: unknown) => Promise<void>) | undefined
71
+ // Desktop native webview probe: 'unknown' until the capability request
72
+ // settles; 'available' means app-private file storage and honest host
73
+ // permission status replace browser storage and permission queries.
74
+ let hostState: 'unknown' | 'available' | 'absent' = 'unknown'
75
+ const hostProbe = cameraHost().then((host) => {
76
+ hostState = host ? 'available' : 'absent'
77
+ return host
78
+ })
79
+
80
+ const lease = {}
81
+ const known: Record<CameraPermissionKind, CameraPermissionStatus> = {
82
+ camera: 'unknown',
83
+ microphone: 'unknown',
84
+ }
85
+
86
+ const browserStorage = () => typeof indexedDB !== 'undefined' && !!navigator.storage?.persisted
87
+
88
+ // A settled desktop-host probe means app-private file storage replaces
89
+ // IndexedDB. While the probe is pending keep the browser answer;
90
+ // `capabilities()` awaits the probe for the settled result.
91
+ const supported = () =>
92
+ typeof navigator !== 'undefined' &&
93
+ globalThis.isSecureContext === true &&
94
+ !!navigator.mediaDevices?.getUserMedia &&
95
+ typeof MediaRecorder !== 'undefined' &&
96
+ !!mimeType() &&
97
+ (hostState === 'available' || browserStorage())
98
+
99
+ const mimeType = () =>
100
+ typeof MediaRecorder === 'undefined'
101
+ ? ''
102
+ : (mimeCandidates.find((mime) => MediaRecorder.isTypeSupported(mime)) ?? '')
103
+
104
+ const markReady = (value: boolean) => {
105
+ ready = value
106
+ observer?.previewReady(value)
107
+ }
108
+
109
+ const dropStream = () => {
110
+ if (host) {
111
+ host.srcObject = null
112
+ }
113
+
114
+ stream?.getTracks().forEach((track) => track.stop())
115
+ stream = undefined
116
+ markReady(false)
117
+ if (owner === lease) {
118
+ owner = undefined
119
+ }
120
+ }
121
+
122
+ const onVisibility = () => {
123
+ if (document.visibilityState === 'hidden') {
124
+ markReady(false)
125
+ observer?.interruption('began', 'background')
126
+ } else {
127
+ observer?.interruption('ended', 'background')
128
+ markReady(!!host && stream?.getVideoTracks()[0]?.readyState === 'live')
129
+ }
130
+ }
131
+
132
+ const onPageHide = () => {
133
+ markReady(false)
134
+ observer?.interruption('began', 'background')
135
+ }
136
+
137
+ // Track-caused interruptions (ended/mute) need an explicit 'ended' —
138
+ // a fresh live stream resolves them, and without the event the core
139
+ // keeps interrupted=true until the session is recreated.
140
+ let deviceInterrupted = false
141
+
142
+ const observeTracks = (target: MediaStream) => {
143
+ for (const track of target.getTracks()) {
144
+ track.addEventListener('ended', () => {
145
+ if (track.kind === 'audio' && (!sink || !recordingAudio)) {
146
+ return
147
+ }
148
+
149
+ if (stream !== target) {
150
+ return
151
+ }
152
+
153
+ markReady(false)
154
+ deviceInterrupted = true
155
+ observer?.availability(false)
156
+ observer?.interruption('began', 'unknown')
157
+ })
158
+
159
+ track.addEventListener('mute', () => {
160
+ if (track.kind === 'audio' && (!sink || !recordingAudio)) {
161
+ return
162
+ }
163
+
164
+ if (stream === target) {
165
+ markReady(false)
166
+ deviceInterrupted = true
167
+ observer?.interruption('began', 'unknown')
168
+ }
169
+ })
170
+
171
+ track.addEventListener('unmute', () => {
172
+ if (stream === target && document.visibilityState !== 'hidden') {
173
+ deviceInterrupted = false
174
+ observer?.interruption('ended', 'unknown')
175
+ observer?.availability(true)
176
+ markReady(!!host)
177
+ }
178
+ })
179
+ }
180
+ }
181
+
182
+ const constraints = (): MediaTrackConstraints => {
183
+ const camera = currentConfig.camera
184
+ return {
185
+ width: { ideal: 1280 },
186
+ height: { ideal: 720 },
187
+ frameRate: { ideal: 30 },
188
+ ...(typeof camera === 'object'
189
+ ? { deviceId: { exact: camera.deviceId } }
190
+ : camera === 'front' || camera === 'back'
191
+ ? { facingMode: { exact: camera === 'front' ? 'user' : 'environment' } }
192
+ : {}),
193
+ }
194
+ }
195
+
196
+ let acquisition: Promise<void> | undefined
197
+ const acquireCameraOnce = async () => {
198
+ if (stream?.getVideoTracks()[0]?.readyState === 'live') {
199
+ return
200
+ }
201
+
202
+ if (owner && owner !== lease) {
203
+ throw error('busy', 'preview', 'Another camera session still owns the source')
204
+ }
205
+
206
+ owner = lease
207
+ const epoch = generation
208
+ try {
209
+ const acquired = await navigator.mediaDevices.getUserMedia({
210
+ video: constraints(),
211
+ audio: false,
212
+ })
213
+
214
+ if (released || epoch !== generation) {
215
+ acquired.getTracks().forEach((track) => track.stop())
216
+ if (!stream && owner === lease) {
217
+ owner = undefined
218
+ }
219
+
220
+ return
221
+ }
222
+
223
+ for (const track of stream?.getAudioTracks() ?? []) {
224
+ acquired.addTrack(track)
225
+ }
226
+
227
+ stream = acquired
228
+ known.camera = 'granted'
229
+ observeTracks(acquired)
230
+ if (deviceInterrupted) {
231
+ deviceInterrupted = false
232
+ observer?.interruption('ended', 'unknown')
233
+ }
234
+
235
+ observer?.availability(true)
236
+ } catch (cause) {
237
+ if (owner === lease) {
238
+ owner = undefined
239
+ }
240
+
241
+ throw cause
242
+ }
243
+ }
244
+
245
+ const acquireCamera = () => {
246
+ acquisition ??= acquireCameraOnce().finally(() => {
247
+ acquisition = undefined
248
+ })
249
+
250
+ return acquisition
251
+ }
252
+
253
+ const showPreview = async () => {
254
+ if (!host || released) {
255
+ return
256
+ }
257
+
258
+ const element = host
259
+ const epoch = generation
260
+ if ((await checkPermission('camera')) !== 'granted') {
261
+ listener?.onError?.(
262
+ error(
263
+ 'permissionBlocked',
264
+ 'preview',
265
+ 'Request camera access from a user action before activating this preview',
266
+ ),
267
+ )
268
+
269
+ return
270
+ }
271
+
272
+ await acquireCamera()
273
+ if (host !== element || !stream || released || epoch !== generation) {
274
+ return
275
+ }
276
+
277
+ element.srcObject = stream
278
+ element.muted = true
279
+ const facing = stream.getVideoTracks()[0]?.getSettings().facingMode
280
+ element.style.transform = facing === 'user' ? 'scaleX(-1)' : ''
281
+ await element.play()
282
+ if (host !== element || released || epoch !== generation) {
283
+ return
284
+ }
285
+
286
+ markReady(document.visibilityState !== 'hidden')
287
+ listener?.onReady?.()
288
+ }
289
+
290
+ const hostMediaKind = (kind: CameraPermissionKind) =>
291
+ kind === 'microphone' ? 'microphone' : 'camera'
292
+
293
+ async function checkPermission(kind: CameraPermissionKind): Promise<CameraPermissionStatus> {
294
+ if (globalThis.isSecureContext !== true || !navigator.mediaDevices?.getUserMedia) {
295
+ return 'unavailable'
296
+ }
297
+
298
+ if (!policyAllows(kind)) {
299
+ return 'blocked'
300
+ }
301
+
302
+ const desktop = await hostProbe
303
+ if (desktop) {
304
+ try {
305
+ const { status } = await desktop.call('camera', 'permissionStatus', {
306
+ kind: hostMediaKind(kind),
307
+ })
308
+
309
+ if (status === 'granted' || status === 'notDetermined') {
310
+ return status
311
+ }
312
+
313
+ if (status === 'restricted') {
314
+ return 'restricted'
315
+ }
316
+
317
+ if (status === 'denied' || status === 'blocked') {
318
+ return 'denied'
319
+ }
320
+
321
+ // 'undeclared' and unknown statuses stay honest but unadorned.
322
+ return 'unknown'
323
+ } catch {
324
+ return 'unknown'
325
+ }
326
+ }
327
+
328
+ try {
329
+ const status = await navigator.permissions.query({ name: kind as PermissionName })
330
+ return status.state === 'granted'
331
+ ? 'granted'
332
+ : status.state === 'denied'
333
+ ? 'blocked'
334
+ : known[kind] === 'blocked'
335
+ ? 'blocked'
336
+ : 'notDetermined'
337
+ } catch {
338
+ if (known[kind] !== 'granted') {
339
+ return known[kind]
340
+ }
341
+
342
+ const tracks = kind === 'camera' ? stream?.getVideoTracks() : stream?.getAudioTracks()
343
+ return tracks?.some((track) => track.readyState === 'live') ? 'granted' : 'unknown'
344
+ }
345
+ }
346
+
347
+ const settle = (value: Parameters<BackendCaptureSink['settled']>[0], expected = sink) => {
348
+ const target = sink
349
+ if (!target || target !== expected) {
350
+ return
351
+ }
352
+
353
+ sink = undefined
354
+ recorder = undefined
355
+ captureFailure = undefined
356
+ clearTimeout(stopTimer)
357
+ clearTimeout(watchdog)
358
+ if (!host) {
359
+ dropStream()
360
+ }
361
+
362
+ target.settled(value)
363
+ }
364
+
365
+ const stop = () => {
366
+ if (!recorder || !sink) {
367
+ return
368
+ }
369
+
370
+ stoppedAt ||= performance.now()
371
+ clearTimeout(watchdog)
372
+ const target = sink
373
+ const fail = captureFailure
374
+ watchdog = setTimeout(() => {
375
+ if (sink !== target) {
376
+ return
377
+ }
378
+
379
+ dropStream()
380
+ void fail?.(error('finalizationFailed', 'stop', 'Recorder finalization callback timed out'))
381
+ }, 30_000)
382
+
383
+ if (recorder.state === 'inactive') {
384
+ return
385
+ }
386
+
387
+ try {
388
+ recorder.stop()
389
+ } catch (cause) {
390
+ void fail?.(error('captureFailed', 'stop', 'The recorder could not stop', cause))
391
+ }
392
+ }
393
+
394
+ return {
395
+ platform: 'web',
396
+ get supported() {
397
+ return supported()
398
+ },
399
+ checkPermission,
400
+ requestPermission: async (kind) => {
401
+ const status = await checkPermission(kind)
402
+ if (status === 'blocked' || status === 'unavailable') {
403
+ return status
404
+ }
405
+
406
+ if (navigator.userActivation && !navigator.userActivation.isActive && status !== 'granted') {
407
+ return 'blocked'
408
+ }
409
+
410
+ // Persistence and device prompts run only inside an explicit app action.
411
+ persistent = (await navigator.storage?.persisted?.().catch(() => false)) ?? false
412
+ if (!persistent && (!navigator.userActivation || navigator.userActivation.isActive)) {
413
+ persistent = (await navigator.storage?.persist?.().catch(() => false)) ?? false
414
+ }
415
+
416
+ const desktop = await hostProbe
417
+ if (desktop) {
418
+ const { status: hostStatus } = await desktop.call('camera', 'requestPermission', {
419
+ kind: hostMediaKind(kind),
420
+ })
421
+
422
+ if (hostStatus === 'undeclared') {
423
+ throw error(
424
+ 'configurationMissing',
425
+ 'requestPermission',
426
+ `Missing ${kind === 'camera' ? 'NSCameraUsageDescription' : 'NSMicrophoneUsageDescription'} in the host app's Info.plist`,
427
+ )
428
+ }
429
+
430
+ if (hostStatus !== 'granted') {
431
+ known[kind] =
432
+ hostStatus === 'denied' || hostStatus === 'blocked'
433
+ ? 'denied'
434
+ : hostStatus === 'restricted'
435
+ ? 'restricted'
436
+ : 'unknown'
437
+
438
+ return known[kind]
439
+ }
440
+ // The system permission is granted — the capture delegate grants
441
+ // this trusted origin, so the acquisition below gets the track.
442
+ }
443
+
444
+ try {
445
+ if (kind === 'camera') {
446
+ await acquireCamera()
447
+ } else if (!stream?.getAudioTracks().some((track) => track.readyState === 'live')) {
448
+ const epoch = generation
449
+ const audio = await navigator.mediaDevices.getUserMedia({ audio: true, video: false })
450
+ if (released || epoch !== generation) {
451
+ audio.getTracks().forEach((track) => track.stop())
452
+ return 'unavailable'
453
+ }
454
+
455
+ if (!stream) {
456
+ stream = new MediaStream()
457
+ }
458
+
459
+ for (const track of audio.getAudioTracks()) {
460
+ stream.addTrack(track)
461
+ }
462
+
463
+ observeTracks(stream)
464
+ }
465
+
466
+ known[kind] = 'granted'
467
+ if (host) {
468
+ await showPreview()
469
+ }
470
+
471
+ return 'granted'
472
+ } catch (cause) {
473
+ known[kind] =
474
+ cause instanceof DOMException &&
475
+ (cause.name === 'NotAllowedError' || cause.name === 'SecurityError')
476
+ ? 'blocked'
477
+ : 'unavailable'
478
+
479
+ return known[kind]
480
+ }
481
+ },
482
+ getCapabilities: async () => {
483
+ const desktop = await hostProbe
484
+ persistent = (await navigator.storage?.persisted?.().catch(() => false)) ?? false
485
+ const devices = (await navigator.mediaDevices?.enumerateDevices?.().catch(() => [])) ?? []
486
+ const settings = stream?.getVideoTracks()[0]?.getSettings()
487
+ const mime = mimeType()
488
+ const storageReady = persistent || !!desktop
489
+ return {
490
+ supported: supported(),
491
+ available: ready && storageReady,
492
+ reason: !supported()
493
+ ? 'Secure media capture, a supported WebM/MP4 codec, and durable storage are required'
494
+ : !storageReady
495
+ ? 'Persistent origin storage must be granted before recording'
496
+ : !ready
497
+ ? 'An active permitted preview is required'
498
+ : undefined,
499
+ cameras: devices
500
+ .filter((device) => device.kind === 'videoinput')
501
+ .map((device) => ({ id: device.deviceId, label: device.label })),
502
+ profiles:
503
+ settings?.width && settings.height && settings.frameRate
504
+ ? [
505
+ {
506
+ profile: 'standard' as const,
507
+ width: settings.width,
508
+ height: settings.height,
509
+ frameRate: settings.frameRate,
510
+ },
511
+ ]
512
+ : [],
513
+ audio: devices.some((device) => device.kind === 'audioinput'),
514
+ // The `pointsOfInterest` track constraint is not wired into
515
+ // the adapter — report honestly rather than fabricate.
516
+ focusPointOfInterest: false,
517
+ exposurePointOfInterest: false,
518
+ orientations: [],
519
+ durationLimit: 'bestEffort',
520
+ elapsedTime: 'estimated',
521
+ output: {
522
+ mimeType: mime,
523
+ container: !mime ? '' : mime.startsWith('video/mp4') ? 'mp4' : 'webm',
524
+ storage: desktop ? 'appPrivateFile' : 'originLocal',
525
+ destinationFileUrl: !!desktop,
526
+ },
527
+ }
528
+ },
529
+ previewReady: () => ready && !!host && !released,
530
+ validateStart: (options) => {
531
+ // Once the desktop-host probe settles absent, browser storage rules
532
+ // apply synchronously. A pending probe defers both checks to the
533
+ // async start path, which knows the real storage owner.
534
+ if (hostState === 'absent') {
535
+ if (!persistent) {
536
+ throw error(
537
+ 'destinationUnavailable',
538
+ 'startRecording',
539
+ 'Persistent origin storage has not been granted; check capabilities after a user permission action',
540
+ )
541
+ }
542
+
543
+ if (options.destinationFileUrl) {
544
+ throw error(
545
+ 'unsupportedConfiguration',
546
+ 'startRecording',
547
+ 'Browser output does not accept native file URLs',
548
+ )
549
+ }
550
+ }
551
+
552
+ if (options.orientation && options.orientation !== 'unspecified') {
553
+ throw error(
554
+ 'unsupportedConfiguration',
555
+ 'startRecording',
556
+ 'This browser cannot lock a recording orientation',
557
+ )
558
+ }
559
+ },
560
+ attachPreview: (target, callbacks) => {
561
+ if (released || sink) {
562
+ throw error('invalidState', 'attachPreview', 'The camera is disposed or still finalizing')
563
+ }
564
+
565
+ host = target as HTMLVideoElement
566
+ listener = callbacks
567
+ document.addEventListener('visibilitychange', onVisibility)
568
+ globalThis.addEventListener('pagehide', onPageHide)
569
+ void showPreview().catch((cause) =>
570
+ callbacks?.onError?.(
571
+ error('unavailable', 'preview', 'Could not activate camera preview', cause),
572
+ ),
573
+ )
574
+
575
+ return () => {
576
+ if (host) {
577
+ host.srcObject = null
578
+ }
579
+
580
+ host = undefined
581
+ listener = undefined
582
+ generation += 1
583
+ markReady(false)
584
+ document.removeEventListener('visibilitychange', onVisibility)
585
+ globalThis.removeEventListener('pagehide', onPageHide)
586
+ if (!sink) {
587
+ dropStream()
588
+ }
589
+ }
590
+ },
591
+ configure: (next) => {
592
+ const sourceChanged =
593
+ JSON.stringify(next.camera) !== JSON.stringify(currentConfig.camera) ||
594
+ next.profile !== currentConfig.profile
595
+
596
+ currentConfig = { ...next }
597
+ if (sourceChanged) {
598
+ generation += 1
599
+ dropStream()
600
+ if (host) {
601
+ void showPreview().catch((cause) =>
602
+ listener?.onError?.(
603
+ error('unavailable', 'configure', 'Could not change camera', cause),
604
+ ),
605
+ )
606
+ }
607
+ }
608
+ },
609
+ startCapture: (request, target) => {
610
+ sink = target
611
+ recordingAudio = request.audio
612
+ startedAt = 0
613
+ stoppedAt = 0
614
+ const source = stream
615
+ const video = source?.getVideoTracks()[0]
616
+ const audio = source?.getAudioTracks().filter((track) => track.readyState === 'live') ?? []
617
+ if (!video || (request.audio && !audio.length)) {
618
+ settle({
619
+ kind: 'failed',
620
+ stage: 'preparation',
621
+ issue: error(
622
+ request.audio ? 'permissionDenied' : 'unavailable',
623
+ 'startRecording',
624
+ request.audio
625
+ ? 'Requested microphone access is missing'
626
+ : 'Video source is unavailable',
627
+ ),
628
+ })
629
+
630
+ return
631
+ }
632
+
633
+ const recordingStream = new MediaStream([video, ...(request.audio ? audio : [])])
634
+ let desktop: CameraHost | null = null
635
+ let reservation: string | undefined
636
+ let stage: 'preparation' | 'capture' | 'finalization' | 'storage' = 'preparation'
637
+ const chunks: Blob[] = []
638
+ let captureError: unknown
639
+ let limitReached = false
640
+ let failing = false
641
+ let didStart = false
642
+ captureFailure = fail
643
+ void (async () => {
644
+ try {
645
+ desktop = await hostProbe
646
+ if (request.destinationPath && !desktop) {
647
+ throw error(
648
+ 'unsupportedConfiguration',
649
+ 'startRecording',
650
+ 'Web recordings do not accept native file destinations',
651
+ )
652
+ }
653
+
654
+ if (desktop) {
655
+ reservation = (
656
+ await reserveHostMovie(desktop, {
657
+ destinationFileUrl: request.destinationPath
658
+ ? `file://${request.destinationPath.split('/').map(encodeURIComponent).join('/')}`
659
+ : undefined,
660
+ container: mimeType().startsWith('video/mp4') ? 'mp4' : 'webm',
661
+ })
662
+ ).fileUrl
663
+ } else {
664
+ if (!(await navigator.storage?.persisted?.())) {
665
+ throw error(
666
+ 'destinationUnavailable',
667
+ 'startRecording',
668
+ 'Persistent storage is unavailable',
669
+ )
670
+ }
671
+
672
+ reservation = await reserveMovie()
673
+ }
674
+
675
+ if (!source || video.readyState !== 'live' || document.visibilityState === 'hidden') {
676
+ throw error(
677
+ 'unavailable',
678
+ 'startRecording',
679
+ 'Camera source became inactive before recording',
680
+ )
681
+ }
682
+
683
+ const activeRecorder = new MediaRecorder(recordingStream, { mimeType: mimeType() })
684
+ recorder = activeRecorder
685
+ activeRecorder.ondataavailable = (event) => {
686
+ if (sink !== target || failing) {
687
+ return
688
+ }
689
+
690
+ if (event.data.size) {
691
+ chunks.push(event.data)
692
+ }
693
+ }
694
+
695
+ activeRecorder.onerror = (event) => {
696
+ if (sink !== target || failing) {
697
+ return
698
+ }
699
+
700
+ captureError = event
701
+ stop()
702
+ }
703
+
704
+ activeRecorder.onstart = () => {
705
+ if (sink !== target || failing) {
706
+ return
707
+ }
708
+
709
+ if (didStart) {
710
+ return
711
+ }
712
+
713
+ didStart = true
714
+ clearTimeout(watchdog)
715
+ startedAt = performance.now()
716
+ stage = 'capture'
717
+ const settings = video.getSettings()
718
+ const effective: EffectiveCaptureConfig = {
719
+ cameraId: settings.deviceId ?? '',
720
+ audio: request.audio,
721
+ width: settings.width ?? 0,
722
+ height: settings.height ?? 0,
723
+ frameRate: settings.frameRate ?? 0,
724
+ orientation: 'unspecified',
725
+ mimeType: activeRecorder.mimeType,
726
+ container: activeRecorder.mimeType.startsWith('video/mp4') ? 'mp4' : 'webm',
727
+ }
728
+
729
+ // The adapter owns a limit even when capabilities() was not called.
730
+ if (request.maximumDurationMs) {
731
+ const limit = request.maximumDurationMs
732
+ const checkLimit = () => {
733
+ if (sink !== target || failing) {
734
+ return
735
+ }
736
+
737
+ const remaining = limit - (performance.now() - startedAt)
738
+ if (remaining > 0) {
739
+ stopTimer = setTimeout(checkLimit, Math.min(remaining, 2_147_483_647))
740
+ } else {
741
+ limitReached = true
742
+ stop()
743
+ }
744
+ }
745
+
746
+ stopTimer = setTimeout(checkLimit, Math.min(limit, 2_147_483_647))
747
+ }
748
+
749
+ target.started(effective)
750
+ }
751
+
752
+ activeRecorder.onstop = () => {
753
+ if (sink !== target || failing) {
754
+ return
755
+ }
756
+
757
+ target.finishing(
758
+ captureError ? 'captureError' : limitReached ? 'maximumDuration' : undefined,
759
+ )
760
+
761
+ stoppedAt ||= performance.now()
762
+ clearTimeout(stopTimer)
763
+ clearTimeout(watchdog)
764
+ void (async () => {
765
+ try {
766
+ stage = 'finalization'
767
+ if (!startedAt) {
768
+ throw error('noMedia', 'finalize', 'No recording start was observed')
769
+ }
770
+
771
+ const bytes = new Blob(chunks, { type: activeRecorder.mimeType })
772
+ const metadata = await inspectMovie(bytes, request.audio)
773
+ if (sink !== target || failing) {
774
+ return
775
+ }
776
+
777
+ stage = 'storage'
778
+ const output = desktop
779
+ ? await commitHostMovie(desktop, reservation!, bytes)
780
+ : {
781
+ kind: 'browserStorage' as const,
782
+ resourceId: reservation!,
783
+ retention: 'persistent' as const,
784
+ }
785
+
786
+ const clip: MovieClip = { ...metadata, output }
787
+ if (!desktop) {
788
+ await commitMovie(reservation!, bytes, clip)
789
+ }
790
+
791
+ settle(
792
+ {
793
+ kind: 'clip',
794
+ ...clip,
795
+ limitReached,
796
+ warning: captureError
797
+ ? error(
798
+ 'captureFailed',
799
+ 'capture',
800
+ 'Recorder reported an error; finalized media was verified',
801
+ captureError,
802
+ )
803
+ : undefined,
804
+ },
805
+ target,
806
+ )
807
+ } catch (cause) {
808
+ await fail(cause)
809
+ }
810
+ })()
811
+ }
812
+
813
+ activeRecorder.start(1000)
814
+ watchdog = setTimeout(() => {
815
+ if (!startedAt && sink === target) {
816
+ stop()
817
+ void fail(
818
+ error('captureFailed', 'startRecording', 'Recorder start callback timed out'),
819
+ )
820
+ }
821
+ }, 15_000)
822
+ } catch (cause) {
823
+ await fail(cause)
824
+ }
825
+ })()
826
+
827
+ async function fail(cause: unknown) {
828
+ if (sink !== target || failing) {
829
+ return
830
+ }
831
+
832
+ failing = true
833
+ if (reservation) {
834
+ await (desktop
835
+ ? removeHostReservation(desktop, reservation)
836
+ : removeReservation(reservation).catch(() => {}))
837
+ }
838
+
839
+ settle(
840
+ {
841
+ kind: 'failed',
842
+ stage,
843
+ issue:
844
+ cause instanceof CameraCaptureError
845
+ ? cause
846
+ : error(
847
+ stage === 'storage'
848
+ ? 'storageFailed'
849
+ : stage === 'preparation' || stage === 'capture'
850
+ ? 'captureFailed'
851
+ : 'finalizationFailed',
852
+ stage,
853
+ 'Movie recording could not complete',
854
+ cause,
855
+ ),
856
+ },
857
+ target,
858
+ )
859
+ }
860
+ },
861
+ stopCapture: stop,
862
+ elapsedMs: () => (startedAt ? Math.max(0, (stoppedAt || performance.now()) - startedAt) : 0),
863
+ openOutput: async (output) => {
864
+ if (output.kind === 'nativeFile') {
865
+ const desktop = await hostProbe
866
+ if (!desktop) {
867
+ throw error(
868
+ 'unavailable',
869
+ 'openOutput',
870
+ 'File-backed output requires the desktop host that recorded it',
871
+ )
872
+ }
873
+
874
+ return openHostMovie(desktop, output)
875
+ }
876
+
877
+ return openStoredMovie(output)
878
+ },
879
+ release: async () => {
880
+ released = true
881
+ generation += 1
882
+ document.removeEventListener('visibilitychange', onVisibility)
883
+ globalThis.removeEventListener('pagehide', onPageHide)
884
+ dropStream()
885
+ host = undefined
886
+ },
887
+ setObserver: (value) => {
888
+ observer = value
889
+ },
890
+ }
891
+ }