@octane-xplat/camera 0.13.0 → 0.15.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 (58) hide show
  1. package/README.md +89 -3
  2. package/package.json +35 -11
  3. package/platforms/android/AndroidManifest.xml +2 -0
  4. package/platforms/android/include.gradle +1 -0
  5. package/platforms/android/java/com/octanexplat/camera/CameraPreviewView.java +13 -0
  6. package/platforms/android/java/com/octanexplat/camera/CameraSessionBridge.java +394 -0
  7. package/platforms/ios/Info.plist +2 -0
  8. package/platforms/macos/src/CameraOwnership.swift +22 -0
  9. package/platforms/macos/src/PhotoCaptureLifecycle.swift +34 -0
  10. package/platforms/macos/src/XplatCameraHost.swift +639 -0
  11. package/platforms/macos/src/XplatPhotoCaptureSheet.swift +192 -0
  12. package/src/CameraView.android.tsrx +27 -3
  13. package/src/CameraView.ios.tsrx +32 -4
  14. package/src/CameraView.macos.tsrx +95 -0
  15. package/src/CameraView.web.tsrx +31 -2
  16. package/src/CameraView.windows.tsrx +126 -0
  17. package/src/camera-permission.mobile.ts +18 -4
  18. package/src/camera-permission.windows.ts +76 -0
  19. package/src/camera-session.macos.ts +75 -0
  20. package/src/camera-session.windows.ts +126 -0
  21. package/src/host-movie.web.ts +295 -0
  22. package/src/index.ts +32 -0
  23. package/src/index.web.ts +32 -0
  24. package/src/layout-child.windows.ts +79 -0
  25. package/src/movie-metadata.web.ts +84 -0
  26. package/src/movie-storage.web.ts +164 -0
  27. package/src/preview-tap.ts +34 -0
  28. package/src/props.ts +14 -1
  29. package/src/session-backend.android.ts +439 -0
  30. package/src/session-backend.ios.ts +1258 -0
  31. package/src/session-backend.linux.ts +887 -0
  32. package/src/session-backend.macos.ts +707 -0
  33. package/src/session-backend.ts +59 -0
  34. package/src/session-backend.web.ts +891 -0
  35. package/src/session-backend.windows.ts +1599 -0
  36. package/src/session-core.ts +723 -0
  37. package/src/session.ts +37 -0
  38. package/src/synthetic-session.ts +377 -0
  39. package/src/types.ts +357 -0
  40. package/types/index.d.ts +43 -0
  41. package/types/props.d.ts +14 -1
  42. package/types/session-core.d.ts +139 -0
  43. package/types/synthetic-session.d.ts +51 -0
  44. package/types/types.d.ts +290 -0
  45. package/dist/native/CameraView.android.js +0 -166
  46. package/dist/native/CameraView.ios.js +0 -131
  47. package/dist/native/camera-permission.mobile.js +0 -45
  48. package/dist/native/camera-session.ios.js +0 -45
  49. package/dist/native/cx.mobile.js +0 -9
  50. package/dist/native/index.js +0 -2
  51. package/dist/native/layout-child.js +0 -53
  52. package/dist/native/octane-client-build.json +0 -8
  53. package/dist/web/CameraView.web.tsrx.js +0 -317
  54. package/dist/web/index.js +0 -2
  55. package/dist/web/layout-child.web.js +0 -29
  56. package/dist/web/octane-client-build.json +0 -8
  57. package/src/CameraView.windows.ts +0 -5
  58. package/src/cx.mobile.ts +0 -6
@@ -0,0 +1,887 @@
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 { inspectMovie } from './movie-metadata.web'
12
+ import type {
13
+ BackendCaptureSink,
14
+ BackendObserver,
15
+ BackendPreviewListener,
16
+ CameraSessionBackend,
17
+ } from './session-core'
18
+
19
+ import type {
20
+ CameraPermissionKind,
21
+ CameraPermissionStatus,
22
+ CameraSessionConfig,
23
+ CaptureIssue,
24
+ EffectiveCaptureConfig,
25
+ MovieClip,
26
+ } from './types'
27
+
28
+ // The probe order mirrors the browser adapter; shipped WebKitGTK hosts may
29
+ // support only video/mp4 (WebM recording needs GStreamer >= 1.24.9 plus the
30
+ // encoder plugins, and MP4 needs the h264/aac encoder + parser plugins). The
31
+ // finalized container is always reported, never assumed.
32
+ const mimeCandidates = [
33
+ 'video/webm;codecs=vp8,opus',
34
+ 'video/webm;codecs=vp9,opus',
35
+ 'video/mp4;codecs=avc1.424028,mp4a.40.2',
36
+ 'video/mp4',
37
+ ]
38
+
39
+ let owner: object | undefined
40
+
41
+ const error = (kind: CaptureIssue['kind'], operation: string, message: string, cause?: unknown) =>
42
+ new CameraCaptureError({ kind, operation, message, cause })
43
+
44
+ const policyAllows = (kind: CameraPermissionKind) => {
45
+ const policy =
46
+ (
47
+ document as Document & {
48
+ permissionsPolicy?: { allowsFeature(feature: string): boolean }
49
+ featurePolicy?: { allowsFeature(feature: string): boolean }
50
+ }
51
+ ).permissionsPolicy ?? (document as any).featurePolicy
52
+
53
+ return policy?.allowsFeature(kind) !== false
54
+ }
55
+
56
+ export function createSessionBackend(config: CameraSessionConfig): CameraSessionBackend {
57
+ let currentConfig = { ...config }
58
+ let observer: BackendObserver | undefined
59
+ let host: HTMLVideoElement | undefined
60
+ let listener: BackendPreviewListener | undefined
61
+ let stream: MediaStream | undefined
62
+ let ready = false
63
+ let released = false
64
+ let generation = 0
65
+ let recorder: MediaRecorder | undefined
66
+ let sink: BackendCaptureSink | undefined
67
+ let startedAt = 0
68
+ let stoppedAt = 0
69
+ let stopTimer: ReturnType<typeof setTimeout> | undefined
70
+ let watchdog: ReturnType<typeof setTimeout> | undefined
71
+ let recordingAudio = false
72
+ let captureFailure: ((cause: unknown) => Promise<void>) | undefined
73
+ // Packaged Linux WebKitGTK host probe: 'unknown' until the capability
74
+ // request settles; 'available' means the host owns media permission
75
+ // decisions and app-private movie storage for this session.
76
+ let hostState: 'unknown' | 'available' | 'absent' = 'unknown'
77
+ const hostProbe = cameraHost().then((host) => {
78
+ hostState = host ? 'available' : 'absent'
79
+ return host
80
+ })
81
+
82
+ const lease = {}
83
+ const known: Record<CameraPermissionKind, CameraPermissionStatus> = {
84
+ camera: 'unknown',
85
+ microphone: 'unknown',
86
+ }
87
+
88
+ // The packaged Linux host is mandatory: it owns media authorization and
89
+ // durable app-private storage — there is no weaker fallback origin store.
90
+ const supported = () =>
91
+ typeof navigator !== 'undefined' &&
92
+ globalThis.isSecureContext === true &&
93
+ !!navigator.mediaDevices?.getUserMedia &&
94
+ typeof MediaRecorder !== 'undefined' &&
95
+ !!mimeType() &&
96
+ hostState === 'available'
97
+
98
+ const mimeType = () =>
99
+ typeof MediaRecorder === 'undefined'
100
+ ? ''
101
+ : (mimeCandidates.find((mime) => MediaRecorder.isTypeSupported(mime)) ?? '')
102
+
103
+ const markReady = (value: boolean) => {
104
+ ready = value
105
+ observer?.previewReady(value)
106
+ }
107
+
108
+ const dropStream = () => {
109
+ if (host) {
110
+ host.srcObject = null
111
+ }
112
+
113
+ stream?.getTracks().forEach((track) => track.stop())
114
+ stream = undefined
115
+ markReady(false)
116
+ if (owner === lease) {
117
+ owner = undefined
118
+ }
119
+ }
120
+
121
+ const onVisibility = () => {
122
+ if (document.visibilityState === 'hidden') {
123
+ markReady(false)
124
+ observer?.interruption('began', 'background')
125
+ } else {
126
+ observer?.interruption('ended', 'background')
127
+ markReady(!!host && stream?.getVideoTracks()[0]?.readyState === 'live')
128
+ }
129
+ }
130
+
131
+ const onPageHide = () => {
132
+ markReady(false)
133
+ observer?.interruption('began', 'background')
134
+ }
135
+
136
+ // Track-caused interruptions (ended/mute) need an explicit 'ended' —
137
+ // a fresh live stream resolves them, and without the event the core
138
+ // keeps interrupted=true until the session is recreated.
139
+ let deviceInterrupted = false
140
+
141
+ const observeTracks = (target: MediaStream) => {
142
+ for (const track of target.getTracks()) {
143
+ track.addEventListener('ended', () => {
144
+ if (track.kind === 'audio' && (!sink || !recordingAudio)) {
145
+ return
146
+ }
147
+
148
+ if (stream !== target) {
149
+ return
150
+ }
151
+
152
+ markReady(false)
153
+ deviceInterrupted = true
154
+ observer?.availability(false)
155
+ observer?.interruption('began', 'unknown')
156
+ })
157
+
158
+ track.addEventListener('mute', () => {
159
+ if (track.kind === 'audio' && (!sink || !recordingAudio)) {
160
+ return
161
+ }
162
+
163
+ if (stream === target) {
164
+ markReady(false)
165
+ deviceInterrupted = true
166
+ observer?.interruption('began', 'unknown')
167
+ }
168
+ })
169
+
170
+ track.addEventListener('unmute', () => {
171
+ if (stream === target && document.visibilityState !== 'hidden') {
172
+ deviceInterrupted = false
173
+ observer?.interruption('ended', 'unknown')
174
+ observer?.availability(true)
175
+ markReady(!!host)
176
+ }
177
+ })
178
+ }
179
+ }
180
+
181
+ const constraints = (): MediaTrackConstraints => {
182
+ const camera = currentConfig.camera
183
+ return {
184
+ width: { ideal: 1280 },
185
+ height: { ideal: 720 },
186
+ frameRate: { ideal: 30 },
187
+ ...(typeof camera === 'object'
188
+ ? { deviceId: { exact: camera.deviceId } }
189
+ : camera === 'front' || camera === 'back'
190
+ ? { facingMode: { exact: camera === 'front' ? 'user' : 'environment' } }
191
+ : {}),
192
+ }
193
+ }
194
+
195
+ let acquisition: Promise<void> | undefined
196
+ const acquireCameraOnce = async () => {
197
+ if (stream?.getVideoTracks()[0]?.readyState === 'live') {
198
+ return
199
+ }
200
+
201
+ if (owner && owner !== lease) {
202
+ throw error('busy', 'preview', 'Another camera session still owns the source')
203
+ }
204
+
205
+ owner = lease
206
+ const epoch = generation
207
+ try {
208
+ const acquired = await navigator.mediaDevices.getUserMedia({
209
+ video: constraints(),
210
+ audio: false,
211
+ })
212
+
213
+ if (released || epoch !== generation) {
214
+ acquired.getTracks().forEach((track) => track.stop())
215
+ if (!stream && owner === lease) {
216
+ owner = undefined
217
+ }
218
+
219
+ return
220
+ }
221
+
222
+ stream = acquired
223
+ known.camera = 'granted'
224
+ observeTracks(acquired)
225
+ if (deviceInterrupted) {
226
+ deviceInterrupted = false
227
+ observer?.interruption('ended', 'unknown')
228
+ }
229
+
230
+ observer?.availability(true)
231
+ } catch (cause) {
232
+ if (owner === lease) {
233
+ owner = undefined
234
+ }
235
+
236
+ throw cause
237
+ }
238
+ }
239
+
240
+ const acquireCamera = () => {
241
+ acquisition ??= acquireCameraOnce().finally(() => {
242
+ acquisition = undefined
243
+ })
244
+
245
+ return acquisition
246
+ }
247
+
248
+ // WebKitGTK's GStreamer recorder loses the video pipeline when an audio
249
+ // track from a separate acquisition joins a video track that has already
250
+ // recorded — audio must come from one combined getUserMedia acquisition.
251
+ // When audio is required, swap the previewed stream for a combined one on
252
+ // the same camera selection.
253
+ const acquireCombined = async () => {
254
+ const epoch = generation
255
+ const acquired = await navigator.mediaDevices.getUserMedia({
256
+ video: constraints(),
257
+ audio: true,
258
+ })
259
+
260
+ if (released || epoch !== generation) {
261
+ acquired.getTracks().forEach((track) => track.stop())
262
+ return
263
+ }
264
+
265
+ const previous = stream
266
+ stream = acquired
267
+ previous?.getTracks().forEach((track) => track.stop())
268
+ observeTracks(acquired)
269
+ if (deviceInterrupted) {
270
+ deviceInterrupted = false
271
+ observer?.interruption('ended', 'unknown')
272
+ }
273
+
274
+ observer?.availability(true)
275
+ }
276
+
277
+ const ensureAudio = async () => {
278
+ if (stream?.getAudioTracks().some((track) => track.readyState === 'live')) {
279
+ return
280
+ }
281
+
282
+ if (stream) {
283
+ await acquireCombined()
284
+ if (host) {
285
+ await showPreview()
286
+ }
287
+
288
+ return
289
+ }
290
+
291
+ // No preview yet — a standalone acquisition proves the grant; the
292
+ // combined swap happens once the camera stream exists.
293
+ const audio = await navigator.mediaDevices.getUserMedia({ audio: true, video: false })
294
+ audio.getTracks().forEach((track) => track.stop())
295
+ }
296
+
297
+ const showPreview = async () => {
298
+ if (!host || released) {
299
+ return
300
+ }
301
+
302
+ const element = host
303
+ const epoch = generation
304
+ if ((await checkPermission('camera')) !== 'granted') {
305
+ listener?.onError?.(
306
+ error(
307
+ 'permissionBlocked',
308
+ 'preview',
309
+ 'Request camera access from a user action before activating this preview',
310
+ ),
311
+ )
312
+
313
+ return
314
+ }
315
+
316
+ await acquireCamera()
317
+ if (host !== element || !stream || released || epoch !== generation) {
318
+ return
319
+ }
320
+
321
+ element.srcObject = stream
322
+ element.muted = true
323
+ const facing = stream.getVideoTracks()[0]?.getSettings().facingMode
324
+ element.style.transform = facing === 'user' ? 'scaleX(-1)' : ''
325
+ await element.play()
326
+ if (host !== element || released || epoch !== generation) {
327
+ return
328
+ }
329
+
330
+ markReady(document.visibilityState !== 'hidden')
331
+ listener?.onReady?.()
332
+ }
333
+
334
+ const hostMediaKind = (kind: CameraPermissionKind) =>
335
+ kind === 'microphone' ? 'microphone' : 'camera'
336
+
337
+ async function checkPermission(kind: CameraPermissionKind): Promise<CameraPermissionStatus> {
338
+ if (globalThis.isSecureContext !== true || !navigator.mediaDevices?.getUserMedia) {
339
+ return 'unavailable'
340
+ }
341
+
342
+ if (!policyAllows(kind)) {
343
+ return 'blocked'
344
+ }
345
+
346
+ const desktop = await hostProbe
347
+ if (!desktop) {
348
+ return 'unavailable'
349
+ }
350
+
351
+ try {
352
+ const { status } = await desktop.call('camera', 'permissionStatus', {
353
+ kind: hostMediaKind(kind),
354
+ })
355
+
356
+ if (status === 'granted' || status === 'notDetermined') {
357
+ return status
358
+ }
359
+
360
+ if (status === 'restricted') {
361
+ return 'restricted'
362
+ }
363
+
364
+ if (status === 'denied' || status === 'blocked') {
365
+ return 'denied'
366
+ }
367
+
368
+ // 'undeclared' and unknown statuses stay honest but unadorned.
369
+ return 'unknown'
370
+ } catch {
371
+ return 'unknown'
372
+ }
373
+ }
374
+
375
+ const settle = (value: Parameters<BackendCaptureSink['settled']>[0], expected = sink) => {
376
+ const target = sink
377
+ if (!target || target !== expected) {
378
+ return
379
+ }
380
+
381
+ sink = undefined
382
+ recorder = undefined
383
+ captureFailure = undefined
384
+ clearTimeout(stopTimer)
385
+ clearTimeout(watchdog)
386
+ if (!host) {
387
+ dropStream()
388
+ }
389
+
390
+ target.settled(value)
391
+ }
392
+
393
+ const stop = () => {
394
+ if (!recorder || !sink) {
395
+ return
396
+ }
397
+
398
+ stoppedAt ||= performance.now()
399
+ clearTimeout(watchdog)
400
+ const target = sink
401
+ const fail = captureFailure
402
+ watchdog = setTimeout(() => {
403
+ if (sink !== target) {
404
+ return
405
+ }
406
+
407
+ dropStream()
408
+ void fail?.(error('finalizationFailed', 'stop', 'Recorder finalization callback timed out'))
409
+ }, 30_000)
410
+
411
+ if (recorder.state === 'inactive') {
412
+ return
413
+ }
414
+
415
+ try {
416
+ recorder.stop()
417
+ } catch (cause) {
418
+ void fail?.(error('captureFailed', 'stop', 'The recorder could not stop', cause))
419
+ }
420
+ }
421
+
422
+ return {
423
+ platform: 'web',
424
+ get supported() {
425
+ return supported()
426
+ },
427
+ checkPermission,
428
+ requestPermission: async (kind) => {
429
+ const status = await checkPermission(kind)
430
+ if (status === 'blocked' || status === 'unavailable') {
431
+ return status
432
+ }
433
+
434
+ const desktop = await hostProbe
435
+ if (!desktop) {
436
+ return 'unavailable'
437
+ }
438
+
439
+ const { status: hostStatus } = await desktop.call('camera', 'requestPermission', {
440
+ kind: hostMediaKind(kind),
441
+ })
442
+
443
+ if (hostStatus !== 'granted') {
444
+ known[kind] =
445
+ hostStatus === 'denied' || hostStatus === 'blocked'
446
+ ? 'denied'
447
+ : hostStatus === 'restricted'
448
+ ? 'restricted'
449
+ : 'unknown'
450
+
451
+ return known[kind]
452
+ }
453
+
454
+ // The host granted the request — its permission-request handler now
455
+ // allows this app's acquisition without another decision.
456
+
457
+ try {
458
+ if (kind === 'camera') {
459
+ await acquireCamera()
460
+ } else if (sink) {
461
+ // Mid-take the recording stream cannot be swapped; prove the
462
+ // grant only — audio joins on the next take.
463
+ const audio = await navigator.mediaDevices.getUserMedia({
464
+ audio: true,
465
+ video: false,
466
+ })
467
+
468
+ audio.getTracks().forEach((track) => track.stop())
469
+ } else {
470
+ await ensureAudio()
471
+ }
472
+
473
+ known[kind] = 'granted'
474
+ if (kind === 'camera' && host) {
475
+ await showPreview()
476
+ }
477
+
478
+ return 'granted'
479
+ } catch (cause) {
480
+ known[kind] =
481
+ cause instanceof DOMException &&
482
+ (cause.name === 'NotAllowedError' || cause.name === 'SecurityError')
483
+ ? 'blocked'
484
+ : 'unavailable'
485
+
486
+ return known[kind]
487
+ }
488
+ },
489
+ getCapabilities: async () => {
490
+ const desktop = await hostProbe
491
+ const devices = (await navigator.mediaDevices?.enumerateDevices?.().catch(() => [])) ?? []
492
+ const settings = stream?.getVideoTracks()[0]?.getSettings()
493
+ const mime = mimeType()
494
+ const storageReady = !!desktop
495
+ return {
496
+ supported: supported(),
497
+ available: ready && storageReady,
498
+ reason: !supported()
499
+ ? 'Secure media capture, a supported WebM/MP4 codec, and the packaged Linux host are required'
500
+ : !storageReady
501
+ ? 'Movie output requires the packaged Linux WebKitGTK host'
502
+ : !ready
503
+ ? 'An active permitted preview is required'
504
+ : undefined,
505
+ cameras: devices
506
+ .filter((device) => device.kind === 'videoinput')
507
+ .map((device) => ({ id: device.deviceId, label: device.label })),
508
+ profiles:
509
+ settings?.width && settings.height && settings.frameRate
510
+ ? [
511
+ {
512
+ profile: 'standard' as const,
513
+ width: settings.width,
514
+ height: settings.height,
515
+ frameRate: settings.frameRate,
516
+ },
517
+ ]
518
+ : [],
519
+ audio: devices.some((device) => device.kind === 'audioinput'),
520
+ // No point-of-interest metering in the WebKitGTK path.
521
+ focusPointOfInterest: false,
522
+ exposurePointOfInterest: false,
523
+ orientations: [],
524
+ durationLimit: 'bestEffort',
525
+ elapsedTime: 'estimated',
526
+ output: {
527
+ mimeType: mime,
528
+ container: !mime ? '' : mime.startsWith('video/mp4') ? 'mp4' : 'webm',
529
+ storage: desktop ? 'appPrivateFile' : 'originLocal',
530
+ destinationFileUrl: !!desktop,
531
+ },
532
+ }
533
+ },
534
+ previewReady: () => ready && !!host && !released,
535
+ validateStart: (options) => {
536
+ // A pending host probe defers checks to the async start path; once
537
+ // the probe settles absent, recording cannot produce durable output.
538
+ if (hostState === 'absent') {
539
+ throw error(
540
+ 'destinationUnavailable',
541
+ 'startRecording',
542
+ 'Movie output requires the packaged Linux WebKitGTK host',
543
+ )
544
+ }
545
+
546
+ if (options.orientation && options.orientation !== 'unspecified') {
547
+ throw error(
548
+ 'unsupportedConfiguration',
549
+ 'startRecording',
550
+ 'This browser cannot lock a recording orientation',
551
+ )
552
+ }
553
+ },
554
+ attachPreview: (target, callbacks) => {
555
+ if (released || sink) {
556
+ throw error('invalidState', 'attachPreview', 'The camera is disposed or still finalizing')
557
+ }
558
+
559
+ host = target as HTMLVideoElement
560
+ listener = callbacks
561
+ document.addEventListener('visibilitychange', onVisibility)
562
+ globalThis.addEventListener('pagehide', onPageHide)
563
+ void showPreview().catch((cause) =>
564
+ callbacks?.onError?.(
565
+ error('unavailable', 'preview', 'Could not activate camera preview', cause),
566
+ ),
567
+ )
568
+
569
+ return () => {
570
+ if (host) {
571
+ host.srcObject = null
572
+ }
573
+
574
+ host = undefined
575
+ listener = undefined
576
+ generation += 1
577
+ markReady(false)
578
+ document.removeEventListener('visibilitychange', onVisibility)
579
+ globalThis.removeEventListener('pagehide', onPageHide)
580
+ if (!sink) {
581
+ dropStream()
582
+ }
583
+ }
584
+ },
585
+ configure: (next) => {
586
+ const sourceChanged =
587
+ JSON.stringify(next.camera) !== JSON.stringify(currentConfig.camera) ||
588
+ next.profile !== currentConfig.profile
589
+
590
+ currentConfig = { ...next }
591
+ if (sourceChanged) {
592
+ generation += 1
593
+ dropStream()
594
+ if (host) {
595
+ void showPreview().catch((cause) =>
596
+ listener?.onError?.(
597
+ error('unavailable', 'configure', 'Could not change camera', cause),
598
+ ),
599
+ )
600
+ }
601
+ }
602
+ },
603
+ startCapture: (request, target) => {
604
+ sink = target
605
+ recordingAudio = request.audio
606
+ startedAt = 0
607
+ stoppedAt = 0
608
+ let desktop: CameraHost | null = null
609
+ let reservation: string | undefined
610
+ let stage: 'preparation' | 'capture' | 'finalization' | 'storage' = 'preparation'
611
+ const chunks: Blob[] = []
612
+ let captureError: unknown
613
+ let limitReached = false
614
+ let failing = false
615
+ let didStart = false
616
+ captureFailure = fail
617
+ void (async () => {
618
+ try {
619
+ desktop = await hostProbe
620
+ if (!desktop) {
621
+ throw error(
622
+ 'destinationUnavailable',
623
+ 'startRecording',
624
+ 'Movie output requires the packaged Linux WebKitGTK host',
625
+ )
626
+ }
627
+
628
+ if (request.audio) {
629
+ // A take never prompts — microphone must already be granted;
630
+ // the combined swap below needs the grant to exist first.
631
+ if ((await checkPermission('microphone')) !== 'granted') {
632
+ throw error(
633
+ 'permissionDenied',
634
+ 'startRecording',
635
+ 'Requested microphone access is missing',
636
+ )
637
+ }
638
+
639
+ await ensureAudio().catch((cause) => {
640
+ throw error(
641
+ 'captureFailed',
642
+ 'startRecording',
643
+ 'Could not acquire microphone for recording',
644
+ cause,
645
+ )
646
+ })
647
+ }
648
+
649
+ const source = stream
650
+ const video = source?.getVideoTracks()[0]
651
+ const audio =
652
+ source?.getAudioTracks().filter((track) => track.readyState === 'live') ?? []
653
+
654
+ if (!video || (request.audio && !audio.length)) {
655
+ throw error(
656
+ request.audio ? 'permissionDenied' : 'unavailable',
657
+ 'startRecording',
658
+ request.audio
659
+ ? 'Requested microphone access is missing'
660
+ : 'Video source is unavailable',
661
+ )
662
+ }
663
+
664
+ const recordingStream = new MediaStream([video, ...(request.audio ? audio : [])])
665
+
666
+ reservation = (
667
+ await reserveHostMovie(desktop, {
668
+ destinationFileUrl: request.destinationPath
669
+ ? `file://${request.destinationPath.split('/').map(encodeURIComponent).join('/')}`
670
+ : undefined,
671
+ container: mimeType().startsWith('video/mp4') ? 'mp4' : 'webm',
672
+ })
673
+ ).fileUrl
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
+ if ((globalThis as any).__xplatDebugMovie) {
773
+ ;(globalThis as any).__xplatLastMovieBlob = bytes
774
+ }
775
+
776
+ const metadata = await inspectMovie(bytes, request.audio)
777
+ if (sink !== target || failing) {
778
+ return
779
+ }
780
+
781
+ stage = 'storage'
782
+ const output = await commitHostMovie(desktop!, reservation!, bytes)
783
+ const clip: MovieClip = { ...metadata, output }
784
+
785
+ settle(
786
+ {
787
+ kind: 'clip',
788
+ ...clip,
789
+ limitReached,
790
+ warning: captureError
791
+ ? error(
792
+ 'captureFailed',
793
+ 'capture',
794
+ 'Recorder reported an error; finalized media was verified',
795
+ captureError,
796
+ )
797
+ : undefined,
798
+ },
799
+ target,
800
+ )
801
+ } catch (cause) {
802
+ await fail(cause)
803
+ }
804
+ })()
805
+ }
806
+
807
+ activeRecorder.start(1000)
808
+ watchdog = setTimeout(() => {
809
+ if (!startedAt && sink === target) {
810
+ stop()
811
+ void fail(
812
+ error('captureFailed', 'startRecording', 'Recorder start callback timed out'),
813
+ )
814
+ }
815
+ }, 15_000)
816
+ } catch (cause) {
817
+ await fail(cause)
818
+ }
819
+ })()
820
+
821
+ async function fail(cause: unknown) {
822
+ if (sink !== target || failing) {
823
+ return
824
+ }
825
+
826
+ failing = true
827
+ if (reservation && desktop) {
828
+ await removeHostReservation(desktop, reservation)
829
+ }
830
+
831
+ settle(
832
+ {
833
+ kind: 'failed',
834
+ stage,
835
+ issue:
836
+ cause instanceof CameraCaptureError
837
+ ? cause
838
+ : error(
839
+ stage === 'storage'
840
+ ? 'storageFailed'
841
+ : stage === 'preparation' || stage === 'capture'
842
+ ? 'captureFailed'
843
+ : 'finalizationFailed',
844
+ stage,
845
+ 'Movie recording could not complete',
846
+ cause,
847
+ ),
848
+ },
849
+ target,
850
+ )
851
+ }
852
+ },
853
+ stopCapture: stop,
854
+ elapsedMs: () => (startedAt ? Math.max(0, (stoppedAt || performance.now()) - startedAt) : 0),
855
+ openOutput: async (output) => {
856
+ if (output.kind !== 'nativeFile') {
857
+ throw error(
858
+ 'unavailable',
859
+ 'openOutput',
860
+ 'Only host file-backed output exists on this platform',
861
+ )
862
+ }
863
+
864
+ const desktop = await hostProbe
865
+ if (!desktop) {
866
+ throw error(
867
+ 'unavailable',
868
+ 'openOutput',
869
+ 'File-backed output requires the desktop host that recorded it',
870
+ )
871
+ }
872
+
873
+ return openHostMovie(desktop, output)
874
+ },
875
+ release: async () => {
876
+ released = true
877
+ generation += 1
878
+ document.removeEventListener('visibilitychange', onVisibility)
879
+ globalThis.removeEventListener('pagehide', onPageHide)
880
+ dropStream()
881
+ host = undefined
882
+ },
883
+ setObserver: (value) => {
884
+ observer = value
885
+ },
886
+ }
887
+ }