@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.
- package/README.md +89 -3
- package/package.json +35 -11
- package/platforms/android/AndroidManifest.xml +2 -0
- package/platforms/android/include.gradle +1 -0
- package/platforms/android/java/com/octanexplat/camera/CameraPreviewView.java +13 -0
- package/platforms/android/java/com/octanexplat/camera/CameraSessionBridge.java +394 -0
- package/platforms/ios/Info.plist +2 -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 +27 -3
- package/src/CameraView.ios.tsrx +32 -4
- package/src/CameraView.macos.tsrx +95 -0
- package/src/CameraView.web.tsrx +31 -2
- package/src/CameraView.windows.tsrx +126 -0
- package/src/camera-permission.mobile.ts +18 -4
- package/src/camera-permission.windows.ts +76 -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 +32 -0
- package/src/index.web.ts +32 -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 +14 -1
- 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 +43 -0
- package/types/props.d.ts +14 -1
- package/types/session-core.d.ts +139 -0
- package/types/synthetic-session.d.ts +51 -0
- package/types/types.d.ts +290 -0
- package/dist/native/CameraView.android.js +0 -166
- package/dist/native/CameraView.ios.js +0 -131
- package/dist/native/camera-permission.mobile.js +0 -45
- package/dist/native/camera-session.ios.js +0 -45
- package/dist/native/cx.mobile.js +0 -9
- package/dist/native/index.js +0 -2
- package/dist/native/layout-child.js +0 -53
- package/dist/native/octane-client-build.json +0 -8
- package/dist/web/CameraView.web.tsrx.js +0 -317
- package/dist/web/index.js +0 -2
- package/dist/web/layout-child.web.js +0 -29
- package/dist/web/octane-client-build.json +0 -8
- package/src/CameraView.windows.ts +0 -5
- 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
|
+
}
|