@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.
- package/LICENSE +21 -0
- package/README.md +135 -0
- package/package.json +104 -0
- package/platforms/android/AndroidManifest.xml +6 -0
- package/platforms/android/include.gradle +14 -0
- package/platforms/android/java/com/octanexplat/camera/CameraPreviewView.java +84 -0
- package/platforms/android/java/com/octanexplat/camera/CameraSessionBridge.java +394 -0
- package/platforms/ios/Info.plist +10 -0
- package/platforms/macos/src/CameraOwnership.swift +22 -0
- package/platforms/macos/src/PhotoCaptureLifecycle.swift +34 -0
- package/platforms/macos/src/XplatCameraHost.swift +639 -0
- package/platforms/macos/src/XplatPhotoCaptureSheet.swift +192 -0
- package/src/CameraView.android.tsrx +150 -0
- package/src/CameraView.ios.tsrx +102 -0
- package/src/CameraView.macos.tsrx +95 -0
- package/src/CameraView.mobile.ts +6 -0
- package/src/CameraView.web.tsrx +132 -0
- package/src/CameraView.windows.tsrx +126 -0
- package/src/camera-permission.mobile.ts +91 -0
- package/src/camera-permission.windows.ts +76 -0
- package/src/camera-session.ios.ts +65 -0
- package/src/camera-session.macos.ts +75 -0
- package/src/camera-session.windows.ts +126 -0
- package/src/host-movie.web.ts +295 -0
- package/src/index.ts +36 -0
- package/src/index.web.ts +34 -0
- package/src/layout-child.ts +81 -0
- package/src/layout-child.web.ts +66 -0
- package/src/layout-child.windows.ts +79 -0
- package/src/movie-metadata.web.ts +84 -0
- package/src/movie-storage.web.ts +164 -0
- package/src/preview-tap.ts +34 -0
- package/src/props.ts +77 -0
- package/src/session-backend.android.ts +439 -0
- package/src/session-backend.ios.ts +1258 -0
- package/src/session-backend.linux.ts +887 -0
- package/src/session-backend.macos.ts +707 -0
- package/src/session-backend.ts +59 -0
- package/src/session-backend.web.ts +891 -0
- package/src/session-backend.windows.ts +1599 -0
- package/src/session-core.ts +723 -0
- package/src/session.ts +37 -0
- package/src/synthetic-session.ts +377 -0
- package/src/types.ts +357 -0
- package/types/index.d.ts +48 -0
- package/types/props.d.ts +61 -0
- package/types/session-core.d.ts +139 -0
- package/types/synthetic-session.d.ts +51 -0
- 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
|
+
}
|