@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.
- package/README.md +63 -3
- package/dist/linux/CameraView.web.tsrx.js +337 -0
- package/dist/linux/host-movie.web.js +100 -0
- package/dist/linux/index.js +5 -0
- package/dist/linux/layout-child.web.js +29 -0
- package/dist/linux/movie-metadata.web.js +65 -0
- package/dist/linux/octane-client-build.json +8 -0
- package/dist/linux/session-backend.linux.js +479 -0
- package/dist/linux/session-core.js +368 -0
- package/dist/linux/session.js +29 -0
- package/dist/linux/synthetic-session.js +280 -0
- package/dist/linux/types.js +19 -0
- package/dist/native/CameraView.android.js +25 -1
- package/dist/native/CameraView.ios.js +24 -1
- package/dist/native/CameraView.windows.js +173 -0
- package/dist/native/camera-permission.windows.js +43 -0
- package/dist/native/camera-session.windows.js +78 -0
- package/dist/native/cx.windows.js +9 -0
- package/dist/native/index.js +4 -1
- package/dist/native/layout-child.windows.js +53 -0
- package/dist/native/octane-client-build.json +1 -1
- package/dist/native/session-backend.android.js +234 -0
- package/dist/native/session-backend.ios.js +682 -0
- package/dist/native/session-backend.js +55 -0
- package/dist/native/session-backend.windows.js +905 -0
- package/dist/native/session-core.js +368 -0
- package/dist/native/session.js +29 -0
- package/dist/native/synthetic-session.js +280 -0
- package/dist/native/types.js +19 -0
- package/dist/web/CameraView.web.tsrx.js +27 -7
- package/dist/web/host-movie.web.js +100 -0
- package/dist/web/index.js +4 -1
- package/dist/web/movie-metadata.web.js +65 -0
- package/dist/web/movie-storage.web.js +119 -0
- package/dist/web/octane-client-build.json +1 -1
- package/dist/web/session-backend.web.js +490 -0
- package/dist/web/session-core.js +368 -0
- package/dist/web/session.js +29 -0
- package/dist/web/synthetic-session.js +280 -0
- package/dist/web/types.js +19 -0
- package/package.json +22 -4
- package/platforms/android/AndroidManifest.xml +2 -0
- package/platforms/android/include.gradle +1 -0
- package/platforms/android/java/com/octanexplat/camera/CameraPreviewView.java +4 -0
- package/platforms/android/java/com/octanexplat/camera/CameraSessionBridge.java +363 -0
- package/platforms/ios/Info.plist +2 -0
- package/platforms/macos/src/XplatCameraHost.swift +513 -0
- package/src/CameraView.android.tsrx +21 -1
- package/src/CameraView.ios.tsrx +28 -2
- package/src/CameraView.macos.tsrx +93 -0
- package/src/CameraView.web.tsrx +20 -2
- package/src/CameraView.windows.tsrx +124 -0
- package/src/camera-permission.windows.ts +76 -0
- package/src/camera-session.macos.ts +75 -0
- package/src/camera-session.windows.ts +123 -0
- package/src/cx.macos.ts +5 -0
- package/src/cx.windows.ts +6 -0
- package/src/host-movie.web.ts +180 -0
- package/src/index.ts +31 -0
- package/src/index.web.ts +31 -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/props.ts +7 -1
- package/src/session-backend.android.ts +397 -0
- package/src/session-backend.ios.ts +1092 -0
- package/src/session-backend.linux.ts +869 -0
- package/src/session-backend.macos.ts +624 -0
- package/src/session-backend.ts +57 -0
- package/src/session-backend.web.ts +874 -0
- package/src/session-backend.windows.ts +1517 -0
- package/src/session-core.ts +659 -0
- package/src/session.ts +37 -0
- package/src/synthetic-session.ts +367 -0
- package/src/types.ts +329 -0
- package/types/index.d.ts +43 -0
- package/types/props.d.ts +7 -1
- package/types/session-core.d.ts +129 -0
- package/types/synthetic-session.d.ts +48 -0
- package/types/types.d.ts +263 -0
- package/src/CameraView.windows.ts +0 -5
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
import { SharedCameraSession } from './session-core'
|
|
2
|
+
import type {
|
|
3
|
+
BackendCaptureRequest,
|
|
4
|
+
BackendCaptureSink,
|
|
5
|
+
BackendObserver,
|
|
6
|
+
BackendSettle,
|
|
7
|
+
CameraSessionBackend,
|
|
8
|
+
} from './session-core'
|
|
9
|
+
|
|
10
|
+
import type {
|
|
11
|
+
CameraInterruptionReason,
|
|
12
|
+
CameraPermissionStatus,
|
|
13
|
+
CameraSession,
|
|
14
|
+
CameraSessionCapabilities,
|
|
15
|
+
CameraSessionConfig,
|
|
16
|
+
MovieOutput,
|
|
17
|
+
} from './types'
|
|
18
|
+
|
|
19
|
+
/** Development/testing seam — a deterministic camera source that drives
|
|
20
|
+
* the shared session state machine without hardware. NOT a production
|
|
21
|
+
* recording source: outcomes carry `synthetic: true` on their output so
|
|
22
|
+
* seam clips stay distinguishable from real capture.
|
|
23
|
+
*
|
|
24
|
+
* `script` is read live at each transition — mutate fields between
|
|
25
|
+
* operations to script permissions, delayed starts, interruptions,
|
|
26
|
+
* partial completions, and failures.
|
|
27
|
+
*/
|
|
28
|
+
export interface SyntheticCameraScript {
|
|
29
|
+
permission: {
|
|
30
|
+
camera: CameraPermissionStatus
|
|
31
|
+
microphone: CameraPermissionStatus
|
|
32
|
+
}
|
|
33
|
+
/** Camera hardware availability observation. */
|
|
34
|
+
available: boolean
|
|
35
|
+
/** Delay before `started` reports; models slow native activation. */
|
|
36
|
+
startDelayMs: number
|
|
37
|
+
/** Finalized clip duration reported by the seam. */
|
|
38
|
+
clipDurationMs: number
|
|
39
|
+
/** Produce a usable clip on stop; false settles `noMedia`. */
|
|
40
|
+
produceMedia: boolean
|
|
41
|
+
/** When set, the attempt settles failed at this stage. */
|
|
42
|
+
failAt?: 'start' | 'capture' | 'finalize' | 'storage'
|
|
43
|
+
/** Emit an interruption after this many ms of recording; the take
|
|
44
|
+
* ends with the mapped end reason and a partial clip. */
|
|
45
|
+
interruption?: {
|
|
46
|
+
afterMs: number
|
|
47
|
+
reason: CameraInterruptionReason
|
|
48
|
+
endsAfterMs?: number
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface SyntheticCameraStorage {
|
|
53
|
+
write(path: string, bytes: Uint8Array): void | Promise<void>
|
|
54
|
+
read(path: string): Uint8Array | undefined | Promise<Uint8Array | undefined>
|
|
55
|
+
delete?(path: string): void | Promise<void>
|
|
56
|
+
exists?(path: string): boolean | Promise<boolean>
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** In-memory storage for the seam — a `fileUrl`-shaped map so outcomes
|
|
60
|
+
* exercise the same reopen path without touching a real filesystem. */
|
|
61
|
+
export function createMemoryCameraStorage(): SyntheticCameraStorage & {
|
|
62
|
+
files: Map<string, Uint8Array>
|
|
63
|
+
} {
|
|
64
|
+
const files = new Map<string, Uint8Array>()
|
|
65
|
+
return {
|
|
66
|
+
files,
|
|
67
|
+
write: (path, bytes) => {
|
|
68
|
+
files.set(path, bytes)
|
|
69
|
+
},
|
|
70
|
+
read: (path) => files.get(path),
|
|
71
|
+
delete: (path) => {
|
|
72
|
+
files.delete(path)
|
|
73
|
+
},
|
|
74
|
+
exists: (path) => files.has(path),
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const defaultScript = (): SyntheticCameraScript => ({
|
|
79
|
+
permission: { camera: 'granted', microphone: 'granted' },
|
|
80
|
+
available: true,
|
|
81
|
+
startDelayMs: 20,
|
|
82
|
+
clipDurationMs: 3000,
|
|
83
|
+
produceMedia: true,
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
const capabilities: CameraSessionCapabilities = {
|
|
87
|
+
supported: true,
|
|
88
|
+
available: true,
|
|
89
|
+
cameras: [
|
|
90
|
+
{ id: 'synthetic-back', label: 'Synthetic back camera', facing: 'back' },
|
|
91
|
+
{ id: 'synthetic-front', label: 'Synthetic front camera', facing: 'front' },
|
|
92
|
+
],
|
|
93
|
+
profiles: [{ profile: 'standard', width: 1280, height: 720, frameRate: 30 }],
|
|
94
|
+
audio: true,
|
|
95
|
+
orientations: ['portrait', 'portraitUpsideDown', 'landscapeLeft', 'landscapeRight'],
|
|
96
|
+
durationLimit: 'native',
|
|
97
|
+
elapsedTime: 'nativeMedia',
|
|
98
|
+
output: {
|
|
99
|
+
mimeType: 'video/quicktime',
|
|
100
|
+
container: 'mov',
|
|
101
|
+
storage: 'appPrivateFile',
|
|
102
|
+
destinationFileUrl: true,
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Create a `CameraSession` backed by the deterministic synthetic source.
|
|
107
|
+
* Marked for development and testing — never present this as live
|
|
108
|
+
* capture to users. */
|
|
109
|
+
export function createSyntheticCameraSession(
|
|
110
|
+
config: CameraSessionConfig = {},
|
|
111
|
+
script: Partial<SyntheticCameraScript> = {},
|
|
112
|
+
storage: SyntheticCameraStorage = createMemoryCameraStorage(),
|
|
113
|
+
): CameraSession {
|
|
114
|
+
const controls: SyntheticCameraScript = { ...defaultScript(), ...script }
|
|
115
|
+
return new SharedCameraSession(createSyntheticBackend(controls, storage), config)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function createSyntheticBackend(
|
|
119
|
+
script: SyntheticCameraScript,
|
|
120
|
+
storage: SyntheticCameraStorage,
|
|
121
|
+
): CameraSessionBackend {
|
|
122
|
+
let observer: BackendObserver | undefined
|
|
123
|
+
let attached = false
|
|
124
|
+
let sink: BackendCaptureSink | undefined
|
|
125
|
+
let startedAt: number | undefined
|
|
126
|
+
let timers: ReturnType<typeof setTimeout>[] = []
|
|
127
|
+
let pendingRequest: BackendCaptureRequest | undefined
|
|
128
|
+
|
|
129
|
+
const later = (ms: number, fn: () => void) => {
|
|
130
|
+
timers.push(setTimeout(fn, ms))
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const clearTimers = () => {
|
|
134
|
+
for (const timer of timers) {
|
|
135
|
+
clearTimeout(timer)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
timers = []
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const settleClip = async (request: BackendCaptureRequest, limitReached: boolean) => {
|
|
142
|
+
const path =
|
|
143
|
+
request.destinationPath ??
|
|
144
|
+
`/synthetic/clip-${Date.now()}-${Math.random().toString(36).slice(2)}.mov`
|
|
145
|
+
|
|
146
|
+
if (!script.produceMedia) {
|
|
147
|
+
sink?.settled({
|
|
148
|
+
kind: 'failed',
|
|
149
|
+
stage: 'capture',
|
|
150
|
+
issue: {
|
|
151
|
+
kind: 'noMedia',
|
|
152
|
+
operation: 'finalize',
|
|
153
|
+
message: 'Synthetic source produced no media',
|
|
154
|
+
},
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (script.failAt === 'finalize') {
|
|
161
|
+
sink?.settled({
|
|
162
|
+
kind: 'failed',
|
|
163
|
+
stage: 'finalization',
|
|
164
|
+
issue: {
|
|
165
|
+
kind: 'finalizationFailed',
|
|
166
|
+
operation: 'finalize',
|
|
167
|
+
message: 'Synthetic finalization failure',
|
|
168
|
+
},
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (script.failAt === 'storage') {
|
|
175
|
+
sink?.settled({
|
|
176
|
+
kind: 'failed',
|
|
177
|
+
stage: 'storage',
|
|
178
|
+
issue: {
|
|
179
|
+
kind: 'storageFailed',
|
|
180
|
+
operation: 'store',
|
|
181
|
+
message: 'Synthetic storage failure',
|
|
182
|
+
},
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
try {
|
|
189
|
+
// Not playable media — seam bytes stand in for a fixture. The
|
|
190
|
+
// output is marked `synthetic` so nothing mistakes it for real
|
|
191
|
+
// capture.
|
|
192
|
+
await storage.write(path, new Uint8Array([0x78, 0x70, 0x6c, 0x61, 0x74]))
|
|
193
|
+
} catch (cause) {
|
|
194
|
+
sink?.settled({
|
|
195
|
+
kind: 'failed',
|
|
196
|
+
stage: 'storage',
|
|
197
|
+
issue: {
|
|
198
|
+
kind: 'storageFailed',
|
|
199
|
+
operation: 'store',
|
|
200
|
+
message: cause instanceof Error ? cause.message : String(cause),
|
|
201
|
+
cause,
|
|
202
|
+
},
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
return
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const output: MovieOutput = {
|
|
209
|
+
kind: 'nativeFile',
|
|
210
|
+
resourceId: `synthetic:${path}`,
|
|
211
|
+
fileUrl: `file://${path}`,
|
|
212
|
+
synthetic: true,
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const settle: BackendSettle = {
|
|
216
|
+
kind: 'clip',
|
|
217
|
+
output,
|
|
218
|
+
mimeType: 'video/quicktime',
|
|
219
|
+
durationMs: script.clipDurationMs,
|
|
220
|
+
width: 1280,
|
|
221
|
+
height: 720,
|
|
222
|
+
orientation: request.orientation ?? 'portrait',
|
|
223
|
+
hasAudio: request.audio,
|
|
224
|
+
limitReached,
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
sink?.settled(settle)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
platform: 'synthetic',
|
|
232
|
+
supported: true,
|
|
233
|
+
checkPermission: async (kind) => script.permission[kind],
|
|
234
|
+
requestPermission: async (kind) => script.permission[kind],
|
|
235
|
+
getCapabilities: async () => ({
|
|
236
|
+
...capabilities,
|
|
237
|
+
available: script.available,
|
|
238
|
+
reason: script.available ? undefined : 'Synthetic camera is unavailable',
|
|
239
|
+
}),
|
|
240
|
+
previewReady: () => attached && script.available,
|
|
241
|
+
attachPreview: (_host, listener) => {
|
|
242
|
+
attached = true
|
|
243
|
+
queueMicrotask(() => {
|
|
244
|
+
listener?.onReady?.()
|
|
245
|
+
observer?.previewReady(true)
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
return () => {
|
|
249
|
+
attached = false
|
|
250
|
+
observer?.previewReady(false)
|
|
251
|
+
}
|
|
252
|
+
},
|
|
253
|
+
startCapture: (request, attemptSink) => {
|
|
254
|
+
sink = attemptSink
|
|
255
|
+
pendingRequest = request
|
|
256
|
+
if (!script.available) {
|
|
257
|
+
attemptSink.settled({
|
|
258
|
+
kind: 'failed',
|
|
259
|
+
stage: 'preparation',
|
|
260
|
+
issue: {
|
|
261
|
+
kind: 'unavailable',
|
|
262
|
+
operation: 'startRecording',
|
|
263
|
+
message: 'Synthetic camera is unavailable',
|
|
264
|
+
},
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
return
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (script.failAt === 'start') {
|
|
271
|
+
later(script.startDelayMs, () => {
|
|
272
|
+
attemptSink.settled({
|
|
273
|
+
kind: 'failed',
|
|
274
|
+
stage: 'preparation',
|
|
275
|
+
issue: {
|
|
276
|
+
kind: 'captureFailed',
|
|
277
|
+
operation: 'startRecording',
|
|
278
|
+
message: 'Synthetic start failure',
|
|
279
|
+
},
|
|
280
|
+
})
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
return
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
later(script.startDelayMs, () => {
|
|
287
|
+
startedAt = Date.now()
|
|
288
|
+
attemptSink.started({
|
|
289
|
+
cameraId: 'synthetic-back',
|
|
290
|
+
facing: 'back',
|
|
291
|
+
audio: request.audio,
|
|
292
|
+
width: 1280,
|
|
293
|
+
height: 720,
|
|
294
|
+
frameRate: 30,
|
|
295
|
+
orientation: request.orientation ?? 'portrait',
|
|
296
|
+
mimeType: 'video/quicktime',
|
|
297
|
+
container: 'mov',
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
if (script.interruption) {
|
|
301
|
+
later(script.interruption.afterMs, () => {
|
|
302
|
+
observer?.interruption('began', script.interruption?.reason)
|
|
303
|
+
if (script.interruption?.endsAfterMs !== undefined) {
|
|
304
|
+
later(script.interruption.endsAfterMs, () => {
|
|
305
|
+
observer?.interruption('ended', script.interruption?.reason)
|
|
306
|
+
})
|
|
307
|
+
}
|
|
308
|
+
})
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (request.maximumDurationMs !== undefined) {
|
|
312
|
+
later(request.maximumDurationMs, () => {
|
|
313
|
+
void settleClip(request, true)
|
|
314
|
+
})
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (script.failAt === 'capture') {
|
|
318
|
+
later(50, () => {
|
|
319
|
+
attemptSink.settled({
|
|
320
|
+
kind: 'failed',
|
|
321
|
+
stage: 'capture',
|
|
322
|
+
issue: {
|
|
323
|
+
kind: 'captureFailed',
|
|
324
|
+
operation: 'record',
|
|
325
|
+
message: 'Synthetic capture failure',
|
|
326
|
+
},
|
|
327
|
+
})
|
|
328
|
+
})
|
|
329
|
+
}
|
|
330
|
+
})
|
|
331
|
+
},
|
|
332
|
+
stopCapture: () => {
|
|
333
|
+
// Settle asynchronously — native stop is async too.
|
|
334
|
+
const request = pendingRequest
|
|
335
|
+
later(5, () => {
|
|
336
|
+
if (request) {
|
|
337
|
+
void settleClip(request, false)
|
|
338
|
+
}
|
|
339
|
+
})
|
|
340
|
+
},
|
|
341
|
+
elapsedMs: () => (startedAt === undefined ? 0 : Date.now() - startedAt),
|
|
342
|
+
openOutput: async (output) => {
|
|
343
|
+
const path = output.kind === 'nativeFile' ? output.fileUrl.slice('file://'.length) : ''
|
|
344
|
+
const exists = storage.exists ? await storage.exists(path) : !!(await storage.read(path))
|
|
345
|
+
if (!exists) {
|
|
346
|
+
throw new Error(`Synthetic clip is missing at ${output.resourceId}`)
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return {
|
|
350
|
+
url: output.kind === 'nativeFile' ? output.fileUrl : output.resourceId,
|
|
351
|
+
fileUrl: output.kind === 'nativeFile' ? output.fileUrl : undefined,
|
|
352
|
+
release: () => {},
|
|
353
|
+
}
|
|
354
|
+
},
|
|
355
|
+
release: async () => {
|
|
356
|
+
attached = false
|
|
357
|
+
clearTimers()
|
|
358
|
+
sink = undefined
|
|
359
|
+
startedAt = undefined
|
|
360
|
+
pendingRequest = undefined
|
|
361
|
+
},
|
|
362
|
+
onPreviewDetached: () => {},
|
|
363
|
+
setObserver: (next) => {
|
|
364
|
+
observer = next
|
|
365
|
+
},
|
|
366
|
+
}
|
|
367
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/** Shared camera-session and movie-recording contract.
|
|
2
|
+
*
|
|
3
|
+
* A `CameraSession` owns one camera acquisition shared between a
|
|
4
|
+
* `CameraView` preview and at most one movie-recording attempt. Recording
|
|
5
|
+
* settles with an immutable `MovieOutcome` only after the local movie is
|
|
6
|
+
* finalized and its metadata is known — a returned clip is always
|
|
7
|
+
* reopenable through `openOutput`.
|
|
8
|
+
*
|
|
9
|
+
* This module is platform-pure: adapters implement
|
|
10
|
+
* `CameraSessionBackend` per host. Adapters that do not support capture
|
|
11
|
+
* yet report `supported: false` and reject `startRecording` with
|
|
12
|
+
* `unsupportedPlatform` instead of fabricating behavior.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Permission resource a `CameraSession` can inspect or request. */
|
|
16
|
+
export type CameraPermissionKind = 'camera' | 'microphone'
|
|
17
|
+
|
|
18
|
+
/** Portable authorization state. Checking never prompts. `blocked` and
|
|
19
|
+
* `unknown` exist so hosts that cannot distinguish user denial from
|
|
20
|
+
* policy refusal do not fabricate `restricted`. */
|
|
21
|
+
export type CameraPermissionStatus =
|
|
22
|
+
| 'notDetermined'
|
|
23
|
+
| 'granted'
|
|
24
|
+
| 'denied'
|
|
25
|
+
| 'restricted'
|
|
26
|
+
| 'blocked'
|
|
27
|
+
| 'unknown'
|
|
28
|
+
| 'unavailable'
|
|
29
|
+
|
|
30
|
+
/** Portable error categories. `unsupported*` covers missing host features,
|
|
31
|
+
* devices, or capture configurations — including adapters that are not
|
|
32
|
+
* implemented yet during staged delivery. */
|
|
33
|
+
export type CameraCaptureErrorKind =
|
|
34
|
+
| 'permissionDenied'
|
|
35
|
+
| 'permissionRestricted'
|
|
36
|
+
| 'permissionBlocked'
|
|
37
|
+
| 'unsupportedPlatform'
|
|
38
|
+
| 'unsupportedConfiguration'
|
|
39
|
+
| 'unavailable'
|
|
40
|
+
| 'busy'
|
|
41
|
+
| 'invalidState'
|
|
42
|
+
| 'invalidArgument'
|
|
43
|
+
| 'destinationUnavailable'
|
|
44
|
+
| 'insufficientStorage'
|
|
45
|
+
| 'noMedia'
|
|
46
|
+
| 'captureFailed'
|
|
47
|
+
| 'finalizationFailed'
|
|
48
|
+
| 'storageFailed'
|
|
49
|
+
| 'configurationMissing'
|
|
50
|
+
|
|
51
|
+
/** Structured diagnostic carried by failures and non-fatal warnings. */
|
|
52
|
+
export interface CaptureIssue {
|
|
53
|
+
kind: CameraCaptureErrorKind
|
|
54
|
+
/** Operation being attempted, e.g. 'startRecording', 'finalize'. */
|
|
55
|
+
operation: string
|
|
56
|
+
message: string
|
|
57
|
+
permission?: CameraPermissionKind
|
|
58
|
+
/** Host-specific detail for logging; never required for control flow. */
|
|
59
|
+
cause?: unknown
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Error thrown or settled by the session API. Satisfies `CaptureIssue`
|
|
63
|
+
* so `error.kind` drives control flow without unwrapping. */
|
|
64
|
+
export class CameraCaptureError extends Error implements CaptureIssue {
|
|
65
|
+
kind: CameraCaptureErrorKind
|
|
66
|
+
operation: string
|
|
67
|
+
permission?: CameraPermissionKind
|
|
68
|
+
cause?: unknown
|
|
69
|
+
|
|
70
|
+
constructor(issue: Omit<CaptureIssue, 'cause'> & { cause?: unknown }) {
|
|
71
|
+
super(issue.message)
|
|
72
|
+
this.name = 'CameraCaptureError'
|
|
73
|
+
this.kind = issue.kind
|
|
74
|
+
this.operation = issue.operation
|
|
75
|
+
this.permission = issue.permission
|
|
76
|
+
this.cause = issue.cause
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export type CameraFacing = 'front' | 'back'
|
|
81
|
+
|
|
82
|
+
export type CameraOrientation =
|
|
83
|
+
| 'portrait'
|
|
84
|
+
| 'portraitUpsideDown'
|
|
85
|
+
| 'landscapeLeft'
|
|
86
|
+
| 'landscapeRight'
|
|
87
|
+
| 'unspecified'
|
|
88
|
+
|
|
89
|
+
/** Camera selection for `CameraSessionConfig.camera`. Desktop and browser
|
|
90
|
+
* hosts expose device identities instead of front/back semantics. */
|
|
91
|
+
export type CameraSelection = 'default' | 'front' | 'back' | { deviceId: string }
|
|
92
|
+
|
|
93
|
+
export interface CameraSessionConfig {
|
|
94
|
+
/** Lens or device identity. Default `'default'`: rear when available. */
|
|
95
|
+
camera?: CameraSelection
|
|
96
|
+
/** Include an audio track; requires microphone permission. Default
|
|
97
|
+
* false — silent capture is a deliberate app choice, never a
|
|
98
|
+
* fallback from denied microphone access. */
|
|
99
|
+
audio?: boolean
|
|
100
|
+
/** Capture profile. `'standard'` targets 720p at 30 fps and may select
|
|
101
|
+
* a device-supported alternative; the started attempt discloses the
|
|
102
|
+
* actual configuration. */
|
|
103
|
+
profile?: 'standard'
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Resolution/frame-rate combination advertised by capability discovery.
|
|
107
|
+
* Entries are supported combinations, not independent lists. */
|
|
108
|
+
export interface CameraProfileCapability {
|
|
109
|
+
profile: 'standard'
|
|
110
|
+
width: number
|
|
111
|
+
height: number
|
|
112
|
+
frameRate: number
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface CameraDeviceInfo {
|
|
116
|
+
id: string
|
|
117
|
+
label: string
|
|
118
|
+
facing?: CameraFacing
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** What the session's host can do right now. `supported: false` means the
|
|
122
|
+
* adapter or host cannot record (reason explains); `available` reflects
|
|
123
|
+
* current hardware/permission state and changes over time. */
|
|
124
|
+
export interface CameraSessionCapabilities {
|
|
125
|
+
supported: boolean
|
|
126
|
+
/** Why capture is unsupported or currently unavailable. */
|
|
127
|
+
reason?: string
|
|
128
|
+
available: boolean
|
|
129
|
+
cameras: CameraDeviceInfo[]
|
|
130
|
+
profiles: CameraProfileCapability[]
|
|
131
|
+
/** The session can add an audio track when permission allows. */
|
|
132
|
+
audio: boolean
|
|
133
|
+
/** Fixed orientations the source can lock for a take. */
|
|
134
|
+
orientations: CameraOrientation[]
|
|
135
|
+
/** `native` enforcement can still overshoot at media boundaries;
|
|
136
|
+
* `bestEffort` limits are scheduled and may overshoot more. */
|
|
137
|
+
durationLimit: 'native' | 'bestEffort'
|
|
138
|
+
/** Where `elapsedMs` comes from while recording. */
|
|
139
|
+
elapsedTime: 'nativeMedia' | 'estimated'
|
|
140
|
+
output: {
|
|
141
|
+
mimeType: string
|
|
142
|
+
container: string
|
|
143
|
+
/** `appPrivateFile` output survives dispose and app restart;
|
|
144
|
+
* `originLocal` is browser storage subject to site-data clearing. */
|
|
145
|
+
storage: 'appPrivateFile' | 'originLocal'
|
|
146
|
+
/** Accepts an app-supplied `destinationFileUrl` on start. */
|
|
147
|
+
destinationFileUrl: boolean
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export type CameraRecorderState = 'idle' | 'starting' | 'recording' | 'finalizing' | 'disposed'
|
|
152
|
+
|
|
153
|
+
/** Frozen capture configuration reported once capture actually begins. */
|
|
154
|
+
export interface EffectiveCaptureConfig {
|
|
155
|
+
cameraId: string
|
|
156
|
+
facing?: CameraFacing
|
|
157
|
+
audio: boolean
|
|
158
|
+
width: number
|
|
159
|
+
height: number
|
|
160
|
+
frameRate: number
|
|
161
|
+
orientation: CameraOrientation
|
|
162
|
+
mimeType: string
|
|
163
|
+
container: string
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export interface CameraSessionSnapshot {
|
|
167
|
+
state: CameraRecorderState
|
|
168
|
+
/** Non-prompting last-known authorization, refreshed by `permissions()`
|
|
169
|
+
* and `requestPermission()`. */
|
|
170
|
+
permission: Record<CameraPermissionKind, CameraPermissionStatus>
|
|
171
|
+
/** Camera hardware/driver availability, separate from recorder state. */
|
|
172
|
+
cameraAvailable: boolean
|
|
173
|
+
/** An active preview view is attached to this session. */
|
|
174
|
+
previewAttached: boolean
|
|
175
|
+
interrupted: boolean
|
|
176
|
+
attempt?: {
|
|
177
|
+
id: string
|
|
178
|
+
state: 'starting' | 'recording' | 'finalizing'
|
|
179
|
+
elapsedMs: number
|
|
180
|
+
configuration?: EffectiveCaptureConfig
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** What ended a capture attempt. `interrupted` covers system suspension
|
|
185
|
+
* and preview teardown; `previewReleased` distinguishes an app-driven
|
|
186
|
+
* deactivation from an OS interruption. */
|
|
187
|
+
export type MovieEndReason =
|
|
188
|
+
| 'stopped'
|
|
189
|
+
| 'maximumDuration'
|
|
190
|
+
| 'interrupted'
|
|
191
|
+
| 'backgrounded'
|
|
192
|
+
| 'cameraUnavailable'
|
|
193
|
+
| 'previewReleased'
|
|
194
|
+
| 'disposed'
|
|
195
|
+
| 'captureError'
|
|
196
|
+
|
|
197
|
+
/** Portable completed-output reference. `resourceId` reopens the clip
|
|
198
|
+
* through `openOutput`; `fileUrl` additionally guarantees file-backed
|
|
199
|
+
* native output. `synthetic` marks test-seam clips. */
|
|
200
|
+
export type MovieOutput =
|
|
201
|
+
| {
|
|
202
|
+
kind: 'nativeFile'
|
|
203
|
+
resourceId: string
|
|
204
|
+
fileUrl: string
|
|
205
|
+
synthetic?: boolean
|
|
206
|
+
}
|
|
207
|
+
| {
|
|
208
|
+
kind: 'browserStorage'
|
|
209
|
+
resourceId: string
|
|
210
|
+
retention: 'persistent' | 'bestEffort'
|
|
211
|
+
synthetic?: boolean
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export interface MovieClip {
|
|
215
|
+
output: MovieOutput
|
|
216
|
+
mimeType: string
|
|
217
|
+
/** Authoritative duration read from the finalized movie. */
|
|
218
|
+
durationMs: number
|
|
219
|
+
/** Presentation dimensions after the orientation transform. */
|
|
220
|
+
width: number
|
|
221
|
+
height: number
|
|
222
|
+
orientation: CameraOrientation
|
|
223
|
+
/** Whether the finalized movie actually contains an audio track.
|
|
224
|
+
* Missing requested audio is a failure, not silent capture. */
|
|
225
|
+
hasAudio: boolean
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** One immutable terminal result per admitted attempt. `partial` marks a
|
|
229
|
+
* usable clip ended early by an external condition — still a success;
|
|
230
|
+
* app review decides whether to keep it. */
|
|
231
|
+
export type MovieOutcome =
|
|
232
|
+
| {
|
|
233
|
+
kind: 'clip'
|
|
234
|
+
clip: MovieClip
|
|
235
|
+
endReason: MovieEndReason
|
|
236
|
+
partial: boolean
|
|
237
|
+
warning?: CaptureIssue
|
|
238
|
+
}
|
|
239
|
+
| {
|
|
240
|
+
kind: 'failed'
|
|
241
|
+
endReason: MovieEndReason
|
|
242
|
+
stage: 'preparation' | 'capture' | 'finalization' | 'storage'
|
|
243
|
+
error: CaptureIssue
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Ordered lifecycle observation, correlated to an attempt when relevant. */
|
|
247
|
+
export type CameraSessionEvent =
|
|
248
|
+
| { type: 'stateChanged'; snapshot: CameraSessionSnapshot }
|
|
249
|
+
| { type: 'started'; attemptId: string; configuration: EffectiveCaptureConfig }
|
|
250
|
+
| { type: 'finished'; attemptId: string; outcome: MovieOutcome }
|
|
251
|
+
| {
|
|
252
|
+
type: 'interruption'
|
|
253
|
+
phase: 'began' | 'ended'
|
|
254
|
+
reason?: CameraInterruptionReason
|
|
255
|
+
}
|
|
256
|
+
| { type: 'availability'; available: boolean }
|
|
257
|
+
|
|
258
|
+
export type CameraInterruptionReason =
|
|
259
|
+
| 'background'
|
|
260
|
+
| 'audioInUse'
|
|
261
|
+
| 'cameraInUse'
|
|
262
|
+
| 'multitasking'
|
|
263
|
+
| 'systemPressure'
|
|
264
|
+
| 'unknown'
|
|
265
|
+
|
|
266
|
+
export interface StartRecordingOptions {
|
|
267
|
+
/** Positive finite milliseconds. Enforcement precision comes from
|
|
268
|
+
* `capabilities().durationLimit`; the final duration is read from
|
|
269
|
+
* the movie and may overshoot. */
|
|
270
|
+
maximumDurationMs?: number
|
|
271
|
+
/** Fixed orientation for the take. Must appear in
|
|
272
|
+
* `capabilities().orientations`; omit for the source default. */
|
|
273
|
+
orientation?: CameraOrientation
|
|
274
|
+
/** App-supplied writable local `file://` URL (native hosts only,
|
|
275
|
+
* where `capabilities().output.destinationFileUrl` is true).
|
|
276
|
+
* Existing files are never overwritten. */
|
|
277
|
+
destinationFileUrl?: string
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** One admitted capture attempt. `completion` is available immediately and
|
|
281
|
+
* settles exactly once with the terminal outcome; repeated `stop()`
|
|
282
|
+
* joins the same finalization. */
|
|
283
|
+
export interface MovieTake {
|
|
284
|
+
readonly id: string
|
|
285
|
+
readonly state: 'starting' | 'recording' | 'finalizing' | 'settled'
|
|
286
|
+
/** Elapsed capture time in milliseconds; excludes preparation and
|
|
287
|
+
* finalization. Source identified by `capabilities().elapsedTime`. */
|
|
288
|
+
readonly elapsedMs: number
|
|
289
|
+
readonly completion: Promise<MovieOutcome>
|
|
290
|
+
/** Requests stop and joins finalization. Returns `completion`. */
|
|
291
|
+
stop(): Promise<MovieOutcome>
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Reopened access to a stored clip. `release()` frees temporary access;
|
|
295
|
+
* it never deletes the clip. */
|
|
296
|
+
export interface MovieOutputHandle {
|
|
297
|
+
/** Playable/readable location for the clip. */
|
|
298
|
+
readonly url: string
|
|
299
|
+
readonly fileUrl?: string
|
|
300
|
+
release(): void
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export interface CameraSession {
|
|
304
|
+
/** Non-prompting permission inspection; may return `unknown` when the
|
|
305
|
+
* host cannot observe authorization. */
|
|
306
|
+
permissions(): Promise<Record<CameraPermissionKind, CameraPermissionStatus>>
|
|
307
|
+
/** Runs the system authorization flow for one kind. Calls are
|
|
308
|
+
* serialized; a status other than `notDetermined` returns without
|
|
309
|
+
* prompting. */
|
|
310
|
+
requestPermission(kind: CameraPermissionKind): Promise<CameraPermissionStatus>
|
|
311
|
+
capabilities(): Promise<CameraSessionCapabilities>
|
|
312
|
+
snapshot(): CameraSessionSnapshot
|
|
313
|
+
/** Ordered lifecycle events. A caller never loses a result by
|
|
314
|
+
* subscribing late — `completion` is authoritative. Listener
|
|
315
|
+
* exceptions do not change capture. */
|
|
316
|
+
subscribe(listener: (event: CameraSessionEvent) => void): () => void
|
|
317
|
+
/** Replaces configuration while idle. Validates against current
|
|
318
|
+
* capabilities and rejects with `invalidState` during an attempt. */
|
|
319
|
+
configure(config: Partial<CameraSessionConfig>): Promise<void>
|
|
320
|
+
/** Admits one capture attempt. Throws `CameraCaptureError` before
|
|
321
|
+
* admission for invalid arguments, busy, invalid state, or
|
|
322
|
+
* unavailable camera — no attempt is created. */
|
|
323
|
+
startRecording(options?: StartRecordingOptions): MovieTake
|
|
324
|
+
/** Reopens a stored output for playback or reading. */
|
|
325
|
+
openOutput(output: MovieOutput): Promise<MovieOutputHandle>
|
|
326
|
+
/** Requests stop on any active attempt, awaits finalization, then
|
|
327
|
+
* releases camera ownership. */
|
|
328
|
+
dispose(): Promise<void>
|
|
329
|
+
}
|