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