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