@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,659 @@
|
|
|
1
|
+
import { CameraCaptureError } from './types'
|
|
2
|
+
import type {
|
|
3
|
+
CameraInterruptionReason,
|
|
4
|
+
CameraPermissionKind,
|
|
5
|
+
CameraPermissionStatus,
|
|
6
|
+
CameraSession,
|
|
7
|
+
CameraSessionCapabilities,
|
|
8
|
+
CameraSessionConfig,
|
|
9
|
+
CameraSessionEvent,
|
|
10
|
+
CameraSessionSnapshot,
|
|
11
|
+
CameraSelection,
|
|
12
|
+
CaptureIssue,
|
|
13
|
+
EffectiveCaptureConfig,
|
|
14
|
+
MovieEndReason,
|
|
15
|
+
MovieOutcome,
|
|
16
|
+
MovieOutput,
|
|
17
|
+
MovieOutputHandle,
|
|
18
|
+
MovieTake,
|
|
19
|
+
StartRecordingOptions,
|
|
20
|
+
} from './types'
|
|
21
|
+
|
|
22
|
+
/** Result an adapter reports when a capture attempt settles. Media
|
|
23
|
+
* metadata is already read from the finalized movie by the adapter. */
|
|
24
|
+
export type BackendSettle =
|
|
25
|
+
| {
|
|
26
|
+
kind: 'clip'
|
|
27
|
+
output: MovieOutput
|
|
28
|
+
mimeType: string
|
|
29
|
+
durationMs: number
|
|
30
|
+
width: number
|
|
31
|
+
height: number
|
|
32
|
+
orientation: EffectiveCaptureConfig['orientation']
|
|
33
|
+
hasAudio: boolean
|
|
34
|
+
/** Native duration-limit enforcement ended the take. */
|
|
35
|
+
limitReached?: boolean
|
|
36
|
+
/** Adapter-driven endings when no earlier app/lifecycle cause exists. */
|
|
37
|
+
endReason?: MovieEndReason
|
|
38
|
+
warning?: CaptureIssue
|
|
39
|
+
}
|
|
40
|
+
| {
|
|
41
|
+
kind: 'failed'
|
|
42
|
+
stage: 'preparation' | 'capture' | 'finalization' | 'storage'
|
|
43
|
+
issue: CaptureIssue
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Per-attempt callbacks handed to `startCapture`. */
|
|
47
|
+
export interface BackendCaptureSink {
|
|
48
|
+
/** Native capture actually began; exactly once per settled attempt. */
|
|
49
|
+
started(config: EffectiveCaptureConfig): void
|
|
50
|
+
/** Capture has ended; metadata verification and storage still own the attempt. */
|
|
51
|
+
finishing(reason?: MovieEndReason): void
|
|
52
|
+
/** Terminal result; exactly once per admitted attempt. */
|
|
53
|
+
settled(result: BackendSettle): void
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface BackendCaptureRequest {
|
|
57
|
+
audio: boolean
|
|
58
|
+
orientation: EffectiveCaptureConfig['orientation'] | undefined
|
|
59
|
+
maximumDurationMs: number | undefined
|
|
60
|
+
/** Resolved writable path for native adapters. */
|
|
61
|
+
destinationPath: string | undefined
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Adapter-owned preview attach. `listener` reports preview lifecycle;
|
|
65
|
+
* returns the detach function. */
|
|
66
|
+
export interface BackendPreviewListener {
|
|
67
|
+
onReady?: () => void
|
|
68
|
+
onError?: (issue: CaptureIssue) => void
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Platform adapter surface driven by the shared session core. Adapters
|
|
72
|
+
* own permission policy, capability discovery, capture, and local output
|
|
73
|
+
* access; the core owns states, ordering, causes, and outcomes. */
|
|
74
|
+
export interface CameraSessionBackend {
|
|
75
|
+
readonly platform: string
|
|
76
|
+
/** False when the adapter cannot capture on this host; the core
|
|
77
|
+
* rejects `startRecording` with `unsupportedPlatform`. */
|
|
78
|
+
readonly supported: boolean
|
|
79
|
+
checkPermission(kind: CameraPermissionKind): Promise<CameraPermissionStatus>
|
|
80
|
+
requestPermission(kind: CameraPermissionKind): Promise<CameraPermissionStatus>
|
|
81
|
+
getCapabilities(): Promise<CameraSessionCapabilities>
|
|
82
|
+
/** An active, ready preview is attached and the camera source is
|
|
83
|
+
* running — the admission requirement for `startRecording`. */
|
|
84
|
+
previewReady(): boolean
|
|
85
|
+
/** Attach the shared-source preview to a platform view. */
|
|
86
|
+
attachPreview(host: unknown, listener?: BackendPreviewListener): () => void
|
|
87
|
+
/** Replace configuration while idle; throws CameraCaptureError. */
|
|
88
|
+
configure?(config: CameraSessionConfig): void
|
|
89
|
+
/** Synchronous platform-specific admission checks; creates no attempt on failure. */
|
|
90
|
+
validateStart?(options: StartRecordingOptions): void
|
|
91
|
+
/** Begin capture for an admitted attempt. Async failures settle via
|
|
92
|
+
* `sink.settled`; a synchronous throw settles as a preparation failure. */
|
|
93
|
+
startCapture(request: BackendCaptureRequest, sink: BackendCaptureSink): void
|
|
94
|
+
/** Request capture stop; settlement still arrives via the sink. */
|
|
95
|
+
stopCapture(): void
|
|
96
|
+
/** Elapsed capture time in ms for the in-flight attempt. */
|
|
97
|
+
elapsedMs(): number
|
|
98
|
+
openOutput(output: MovieOutput): Promise<MovieOutputHandle>
|
|
99
|
+
/** Release camera ownership. The core calls this only after any
|
|
100
|
+
* in-flight attempt has settled. */
|
|
101
|
+
release(): Promise<void>
|
|
102
|
+
/** Reattach/reconfigure bookkeeping when the preview view is detached
|
|
103
|
+
* while an attempt is settling — the adapter keeps hardware until
|
|
104
|
+
* settlement, then stops. */
|
|
105
|
+
onPreviewDetached?(): void
|
|
106
|
+
/** Lifecycle observations the adapter emits at any time. */
|
|
107
|
+
setObserver(observer: BackendObserver | undefined): void
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface BackendObserver {
|
|
111
|
+
interruption(phase: 'began' | 'ended', reason?: CameraInterruptionReason): void
|
|
112
|
+
availability(available: boolean): void
|
|
113
|
+
/** Preview became ready/unready (session running state changed). */
|
|
114
|
+
previewReady(ready: boolean): void
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
let attemptCounter = 0
|
|
118
|
+
|
|
119
|
+
const issue = (
|
|
120
|
+
kind: CaptureIssue['kind'],
|
|
121
|
+
operation: string,
|
|
122
|
+
message: string,
|
|
123
|
+
extra?: Partial<CaptureIssue>,
|
|
124
|
+
): CameraCaptureError => new CameraCaptureError({ kind, operation, message, ...extra })
|
|
125
|
+
|
|
126
|
+
/** The concrete session object wrapping a platform backend. Internal to
|
|
127
|
+
* the package; `CameraSession` is the public shape. */
|
|
128
|
+
export class SharedCameraSession implements CameraSession {
|
|
129
|
+
private backend: CameraSessionBackend
|
|
130
|
+
private config: CameraSessionConfig
|
|
131
|
+
private listeners = new Set<(event: CameraSessionEvent) => void>()
|
|
132
|
+
private state: CameraSessionSnapshot['state'] = 'idle'
|
|
133
|
+
private permission: Record<CameraPermissionKind, CameraPermissionStatus> = {
|
|
134
|
+
camera: 'unknown',
|
|
135
|
+
microphone: 'unknown',
|
|
136
|
+
}
|
|
137
|
+
private cameraAvailable = true
|
|
138
|
+
private previewAttached = false
|
|
139
|
+
private interrupted = false
|
|
140
|
+
private disposeRequested = false
|
|
141
|
+
private disposed = false
|
|
142
|
+
private lastCapabilities: CameraSessionCapabilities | undefined
|
|
143
|
+
private requestChains = new Map<CameraPermissionKind, Promise<CameraPermissionStatus>>()
|
|
144
|
+
|
|
145
|
+
private attempt:
|
|
146
|
+
| {
|
|
147
|
+
id: string
|
|
148
|
+
state: 'starting' | 'recording' | 'finalizing' | 'settled'
|
|
149
|
+
stopRequested: boolean
|
|
150
|
+
cause?: MovieEndReason
|
|
151
|
+
startedAt?: number
|
|
152
|
+
configuration?: EffectiveCaptureConfig
|
|
153
|
+
resolve: (outcome: MovieOutcome) => void
|
|
154
|
+
completion: Promise<MovieOutcome>
|
|
155
|
+
limitTimer?: ReturnType<typeof setTimeout>
|
|
156
|
+
}
|
|
157
|
+
| undefined
|
|
158
|
+
|
|
159
|
+
constructor(backend: CameraSessionBackend, config: CameraSessionConfig) {
|
|
160
|
+
this.backend = backend
|
|
161
|
+
this.config = { ...config }
|
|
162
|
+
backend.setObserver({
|
|
163
|
+
interruption: (phase, reason) => this.handleInterruption(phase, reason),
|
|
164
|
+
availability: (available) => this.handleAvailability(available),
|
|
165
|
+
previewReady: () => {
|
|
166
|
+
this.emit({ type: 'stateChanged', snapshot: this.snapshot() })
|
|
167
|
+
},
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Package-internal: attach a preview host to this session. */
|
|
172
|
+
attach(host: unknown, listener?: BackendPreviewListener): () => void {
|
|
173
|
+
if (this.disposed || this.disposeRequested) {
|
|
174
|
+
throw issue('invalidState', 'attachPreview', 'CameraSession is disposed')
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (this.previewAttached) {
|
|
178
|
+
throw issue('invalidState', 'attachPreview', 'CameraSession already has a preview')
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const detach = this.backend.attachPreview(host, {
|
|
182
|
+
onReady: listener?.onReady,
|
|
183
|
+
onError: (captureIssue) => {
|
|
184
|
+
listener?.onError?.(captureIssue)
|
|
185
|
+
},
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
this.previewAttached = true
|
|
189
|
+
this.emit({ type: 'stateChanged', snapshot: this.snapshot() })
|
|
190
|
+
return () => {
|
|
191
|
+
this.detachInternal(detach)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
get attached(): boolean {
|
|
196
|
+
return this.previewAttached
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private detachInternal(detach: () => void) {
|
|
200
|
+
if (!this.previewAttached) {
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
this.previewAttached = false
|
|
205
|
+
const attempt = this.attempt
|
|
206
|
+
if (attempt && (attempt.state === 'starting' || attempt.state === 'recording')) {
|
|
207
|
+
this.requestStop('previewReleased')
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
detach()
|
|
211
|
+
this.backend.onPreviewDetached?.()
|
|
212
|
+
this.emit({ type: 'stateChanged', snapshot: this.snapshot() })
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private emit(event: CameraSessionEvent) {
|
|
216
|
+
for (const listener of this.listeners) {
|
|
217
|
+
try {
|
|
218
|
+
listener(event)
|
|
219
|
+
} catch {
|
|
220
|
+
// Listener exceptions must not change capture behavior.
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private setState(state: CameraSessionSnapshot['state']) {
|
|
226
|
+
if (this.state === state) {
|
|
227
|
+
return
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
this.state = state
|
|
231
|
+
this.emit({ type: 'stateChanged', snapshot: this.snapshot() })
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
snapshot(): CameraSessionSnapshot {
|
|
235
|
+
const attempt = this.attempt
|
|
236
|
+
return {
|
|
237
|
+
state: this.state,
|
|
238
|
+
permission: { ...this.permission },
|
|
239
|
+
cameraAvailable: this.cameraAvailable,
|
|
240
|
+
previewAttached: this.previewAttached,
|
|
241
|
+
interrupted: this.interrupted,
|
|
242
|
+
attempt:
|
|
243
|
+
attempt && attempt.state !== 'settled'
|
|
244
|
+
? {
|
|
245
|
+
id: attempt.id,
|
|
246
|
+
state: attempt.state,
|
|
247
|
+
elapsedMs: this.backend.elapsedMs(),
|
|
248
|
+
configuration: attempt.configuration,
|
|
249
|
+
}
|
|
250
|
+
: undefined,
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
subscribe(listener: (event: CameraSessionEvent) => void): () => void {
|
|
255
|
+
this.listeners.add(listener)
|
|
256
|
+
return () => {
|
|
257
|
+
this.listeners.delete(listener)
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async permissions() {
|
|
262
|
+
const [camera, microphone] = await Promise.all([
|
|
263
|
+
this.backend.checkPermission('camera'),
|
|
264
|
+
this.backend.checkPermission('microphone'),
|
|
265
|
+
])
|
|
266
|
+
|
|
267
|
+
this.permission = { camera, microphone }
|
|
268
|
+
this.emit({ type: 'stateChanged', snapshot: this.snapshot() })
|
|
269
|
+
return { ...this.permission }
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
requestPermission(kind: CameraPermissionKind): Promise<CameraPermissionStatus> {
|
|
273
|
+
const chain = (
|
|
274
|
+
this.requestChains.get(kind) ?? Promise.resolve('unknown' as CameraPermissionStatus)
|
|
275
|
+
).then(async () => {
|
|
276
|
+
if (this.disposed) {
|
|
277
|
+
return this.permission[kind]
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const status = await this.backend.requestPermission(kind)
|
|
281
|
+
this.permission = { ...this.permission, [kind]: status }
|
|
282
|
+
this.emit({ type: 'stateChanged', snapshot: this.snapshot() })
|
|
283
|
+
return status
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
this.requestChains.set(kind, chain)
|
|
287
|
+
return chain
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async capabilities(): Promise<CameraSessionCapabilities> {
|
|
291
|
+
const capabilities = await this.backend.getCapabilities()
|
|
292
|
+
this.lastCapabilities = capabilities
|
|
293
|
+
return capabilities
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async configure(next: Partial<CameraSessionConfig>): Promise<void> {
|
|
297
|
+
if (this.disposed || this.disposeRequested) {
|
|
298
|
+
throw issue('invalidState', 'configure', 'CameraSession is disposed')
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (this.state !== 'idle') {
|
|
302
|
+
throw issue('invalidState', 'configure', `CameraSession cannot configure while ${this.state}`)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const merged: CameraSessionConfig = { ...this.config, ...next }
|
|
306
|
+
const capabilities = await this.capabilities()
|
|
307
|
+
this.validateCameraSelection(merged.camera, capabilities)
|
|
308
|
+
this.config = merged
|
|
309
|
+
this.backend.configure?.(merged)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private validateCameraSelection(
|
|
313
|
+
camera: CameraSelection | undefined,
|
|
314
|
+
capabilities: CameraSessionCapabilities,
|
|
315
|
+
) {
|
|
316
|
+
if (camera && typeof camera === 'object' && 'deviceId' in camera) {
|
|
317
|
+
const known = capabilities.cameras.some((entry) => entry.id === camera.deviceId)
|
|
318
|
+
if (!known) {
|
|
319
|
+
throw issue(
|
|
320
|
+
'unsupportedConfiguration',
|
|
321
|
+
'configure',
|
|
322
|
+
`Camera device '${camera.deviceId}' is not available`,
|
|
323
|
+
)
|
|
324
|
+
}
|
|
325
|
+
} else if (camera === 'front' || camera === 'back') {
|
|
326
|
+
const known = capabilities.cameras.some((entry) => entry.facing === camera)
|
|
327
|
+
if (!known && capabilities.cameras.length > 0) {
|
|
328
|
+
throw issue(
|
|
329
|
+
'unsupportedConfiguration',
|
|
330
|
+
'configure',
|
|
331
|
+
`No ${camera}-facing camera is available`,
|
|
332
|
+
)
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
private requestStop(cause: MovieEndReason) {
|
|
338
|
+
const attempt = this.attempt
|
|
339
|
+
if (!attempt || attempt.state === 'finalizing' || attempt.state === 'settled') {
|
|
340
|
+
return
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
attempt.stopRequested = true
|
|
344
|
+
attempt.cause ??= cause
|
|
345
|
+
if (attempt.state === 'recording') {
|
|
346
|
+
this.setState('finalizing')
|
|
347
|
+
attempt.state = 'finalizing'
|
|
348
|
+
try {
|
|
349
|
+
this.backend.stopCapture()
|
|
350
|
+
} catch {
|
|
351
|
+
// A stop failure still resolves through the settle path or a
|
|
352
|
+
// backend timeout; nothing more to do here.
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
private handleInterruption(phase: 'began' | 'ended', reason?: CameraInterruptionReason) {
|
|
358
|
+
this.interrupted = phase === 'began'
|
|
359
|
+
this.emit({ type: 'interruption', phase, reason })
|
|
360
|
+
if (phase === 'began') {
|
|
361
|
+
const cause: MovieEndReason =
|
|
362
|
+
reason === 'background'
|
|
363
|
+
? 'backgrounded'
|
|
364
|
+
: reason === 'cameraInUse'
|
|
365
|
+
? 'cameraUnavailable'
|
|
366
|
+
: 'interrupted'
|
|
367
|
+
|
|
368
|
+
this.requestStop(cause)
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
this.emit({ type: 'stateChanged', snapshot: this.snapshot() })
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
private handleAvailability(available: boolean) {
|
|
375
|
+
if (this.cameraAvailable === available) {
|
|
376
|
+
return
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
this.cameraAvailable = available
|
|
380
|
+
this.emit({ type: 'availability', available })
|
|
381
|
+
if (!available) {
|
|
382
|
+
this.requestStop('cameraUnavailable')
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
this.emit({ type: 'stateChanged', snapshot: this.snapshot() })
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
startRecording(options: StartRecordingOptions = {}): MovieTake {
|
|
389
|
+
const operation = 'startRecording'
|
|
390
|
+
if (this.disposed || this.disposeRequested) {
|
|
391
|
+
throw issue('invalidState', operation, 'CameraSession is disposed')
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (!this.backend.supported) {
|
|
395
|
+
throw issue(
|
|
396
|
+
'unsupportedPlatform',
|
|
397
|
+
operation,
|
|
398
|
+
'Movie recording is unsupported on this platform',
|
|
399
|
+
)
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (this.state !== 'idle' || this.attempt) {
|
|
403
|
+
throw issue('busy', operation, `CameraSession is ${this.state}`)
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (options.maximumDurationMs !== undefined) {
|
|
407
|
+
const value = options.maximumDurationMs
|
|
408
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
409
|
+
throw issue(
|
|
410
|
+
'invalidArgument',
|
|
411
|
+
operation,
|
|
412
|
+
'maximumDurationMs must be a positive finite number of milliseconds',
|
|
413
|
+
)
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (
|
|
418
|
+
options.destinationFileUrl !== undefined &&
|
|
419
|
+
!options.destinationFileUrl.startsWith('file://')
|
|
420
|
+
) {
|
|
421
|
+
throw issue('invalidArgument', operation, 'destinationFileUrl must be a local file:// URL')
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (
|
|
425
|
+
options.orientation !== undefined &&
|
|
426
|
+
options.orientation !== 'unspecified' &&
|
|
427
|
+
this.lastCapabilities &&
|
|
428
|
+
!this.lastCapabilities.orientations.includes(options.orientation)
|
|
429
|
+
) {
|
|
430
|
+
throw issue(
|
|
431
|
+
'unsupportedConfiguration',
|
|
432
|
+
'startRecording',
|
|
433
|
+
`Orientation '${options.orientation}' is not advertised by this camera`,
|
|
434
|
+
)
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
if (this.interrupted || !this.cameraAvailable || !this.backend.previewReady()) {
|
|
438
|
+
throw issue(
|
|
439
|
+
'unavailable',
|
|
440
|
+
operation,
|
|
441
|
+
this.interrupted
|
|
442
|
+
? 'Camera session is interrupted'
|
|
443
|
+
: !this.cameraAvailable
|
|
444
|
+
? 'Camera is unavailable'
|
|
445
|
+
: 'An active, ready preview is required before recording',
|
|
446
|
+
)
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
this.backend.validateStart?.(options)
|
|
450
|
+
|
|
451
|
+
attemptCounter += 1
|
|
452
|
+
const id = `take-${Date.now()}-${attemptCounter}`
|
|
453
|
+
let resolveCompletion!: (outcome: MovieOutcome) => void
|
|
454
|
+
const completion = new Promise<MovieOutcome>((resolve) => {
|
|
455
|
+
resolveCompletion = resolve
|
|
456
|
+
})
|
|
457
|
+
|
|
458
|
+
const attempt: {
|
|
459
|
+
id: string
|
|
460
|
+
state: 'starting' | 'recording' | 'finalizing' | 'settled'
|
|
461
|
+
stopRequested: boolean
|
|
462
|
+
cause?: MovieEndReason
|
|
463
|
+
startedAt?: number
|
|
464
|
+
configuration?: EffectiveCaptureConfig
|
|
465
|
+
resolve: (outcome: MovieOutcome) => void
|
|
466
|
+
completion: Promise<MovieOutcome>
|
|
467
|
+
limitTimer?: ReturnType<typeof setTimeout>
|
|
468
|
+
} = {
|
|
469
|
+
id,
|
|
470
|
+
state: 'starting',
|
|
471
|
+
stopRequested: false,
|
|
472
|
+
resolve: resolveCompletion,
|
|
473
|
+
completion,
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
this.attempt = attempt
|
|
477
|
+
this.setState('starting')
|
|
478
|
+
|
|
479
|
+
const capabilities = this.lastCapabilities
|
|
480
|
+
const sink: BackendCaptureSink = {
|
|
481
|
+
started: (configuration) => {
|
|
482
|
+
const current = this.attempt
|
|
483
|
+
if (!current || current.id !== id || current.state === 'settled') {
|
|
484
|
+
return
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
current.configuration = configuration
|
|
488
|
+
current.startedAt = Date.now()
|
|
489
|
+
if (current.state === 'starting') {
|
|
490
|
+
current.state = 'recording'
|
|
491
|
+
this.setState('recording')
|
|
492
|
+
this.emit({ type: 'started', attemptId: id, configuration })
|
|
493
|
+
if (
|
|
494
|
+
options.maximumDurationMs !== undefined &&
|
|
495
|
+
capabilities?.durationLimit === 'bestEffort'
|
|
496
|
+
) {
|
|
497
|
+
const limit = options.maximumDurationMs
|
|
498
|
+
const checkLimit = () => {
|
|
499
|
+
if (this.attempt?.id !== id || current.state !== 'recording') {
|
|
500
|
+
return
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const remaining = limit - this.backend.elapsedMs()
|
|
504
|
+
if (remaining > 0) {
|
|
505
|
+
current.limitTimer = setTimeout(checkLimit, Math.min(remaining, 2_147_483_647))
|
|
506
|
+
} else {
|
|
507
|
+
this.requestStop('maximumDuration')
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
current.limitTimer = setTimeout(checkLimit, Math.min(limit, 2_147_483_647))
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (current.stopRequested) {
|
|
515
|
+
this.requestStop(current.cause ?? 'stopped')
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
},
|
|
519
|
+
finishing: (reason) => {
|
|
520
|
+
const current = this.attempt
|
|
521
|
+
if (!current || current.id !== id || current.state === 'settled') {
|
|
522
|
+
return
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
current.cause ??= reason
|
|
526
|
+
current.state = 'finalizing'
|
|
527
|
+
this.setState('finalizing')
|
|
528
|
+
},
|
|
529
|
+
settled: (result) => {
|
|
530
|
+
const current = this.attempt
|
|
531
|
+
if (!current || current.id !== id || current.state === 'settled') {
|
|
532
|
+
return
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
if (current.limitTimer) {
|
|
536
|
+
clearTimeout(current.limitTimer)
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
current.state = 'settled'
|
|
540
|
+
const cause = current.cause ?? (result.kind === 'clip' ? result.endReason : undefined)
|
|
541
|
+
const outcome: MovieOutcome =
|
|
542
|
+
result.kind === 'clip'
|
|
543
|
+
? {
|
|
544
|
+
kind: 'clip',
|
|
545
|
+
clip: {
|
|
546
|
+
output: result.output,
|
|
547
|
+
mimeType: result.mimeType,
|
|
548
|
+
durationMs: result.durationMs,
|
|
549
|
+
width: result.width,
|
|
550
|
+
height: result.height,
|
|
551
|
+
orientation: result.orientation,
|
|
552
|
+
hasAudio: result.hasAudio,
|
|
553
|
+
},
|
|
554
|
+
endReason: cause ?? (result.limitReached ? 'maximumDuration' : 'stopped'),
|
|
555
|
+
partial: cause !== undefined && cause !== 'stopped' && cause !== 'maximumDuration',
|
|
556
|
+
warning: result.warning,
|
|
557
|
+
}
|
|
558
|
+
: {
|
|
559
|
+
kind: 'failed',
|
|
560
|
+
endReason: cause ?? 'captureError',
|
|
561
|
+
stage: result.stage,
|
|
562
|
+
error: new CameraCaptureError(result.issue),
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
this.attempt = undefined
|
|
566
|
+
current.resolve(outcome)
|
|
567
|
+
this.emit({ type: 'finished', attemptId: id, outcome })
|
|
568
|
+
if (this.disposeRequested) {
|
|
569
|
+
void this.finishDispose()
|
|
570
|
+
} else {
|
|
571
|
+
this.setState('idle')
|
|
572
|
+
}
|
|
573
|
+
},
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const request: BackendCaptureRequest = {
|
|
577
|
+
audio: this.config.audio === true,
|
|
578
|
+
orientation: options.orientation,
|
|
579
|
+
maximumDurationMs: options.maximumDurationMs,
|
|
580
|
+
destinationPath: options.destinationFileUrl?.startsWith('file://')
|
|
581
|
+
? decodeURIComponent(options.destinationFileUrl.slice('file://'.length))
|
|
582
|
+
: undefined,
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
try {
|
|
586
|
+
this.backend.startCapture(request, sink)
|
|
587
|
+
} catch (cause) {
|
|
588
|
+
sink.settled({
|
|
589
|
+
kind: 'failed',
|
|
590
|
+
stage: 'preparation',
|
|
591
|
+
issue:
|
|
592
|
+
cause instanceof CameraCaptureError
|
|
593
|
+
? cause
|
|
594
|
+
: {
|
|
595
|
+
kind: 'captureFailed',
|
|
596
|
+
operation: 'startRecording',
|
|
597
|
+
message: cause instanceof Error ? cause.message : String(cause),
|
|
598
|
+
cause,
|
|
599
|
+
},
|
|
600
|
+
})
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const backend = this.backend
|
|
604
|
+
const take: MovieTake = {
|
|
605
|
+
id,
|
|
606
|
+
get state() {
|
|
607
|
+
return attempt.state
|
|
608
|
+
},
|
|
609
|
+
get elapsedMs() {
|
|
610
|
+
return attempt.state === 'recording' || attempt.state === 'finalizing'
|
|
611
|
+
? backend.elapsedMs()
|
|
612
|
+
: 0
|
|
613
|
+
},
|
|
614
|
+
completion,
|
|
615
|
+
stop: () => {
|
|
616
|
+
if (this.attempt?.id === id) {
|
|
617
|
+
this.requestStop('stopped')
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
return completion
|
|
621
|
+
},
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
return take
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
openOutput(output: MovieOutput): Promise<MovieOutputHandle> {
|
|
628
|
+
return this.backend.openOutput(output)
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
async dispose(): Promise<void> {
|
|
632
|
+
if (this.disposed) {
|
|
633
|
+
return
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
this.disposeRequested = true
|
|
637
|
+
const attempt = this.attempt
|
|
638
|
+
if (attempt && attempt.state !== 'settled') {
|
|
639
|
+
this.requestStop('disposed')
|
|
640
|
+
await attempt.completion
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
await this.finishDispose()
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
private async finishDispose() {
|
|
647
|
+
if (this.disposed) {
|
|
648
|
+
return
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
this.disposed = true
|
|
652
|
+
this.backend.setObserver(undefined)
|
|
653
|
+
try {
|
|
654
|
+
await this.backend.release()
|
|
655
|
+
} finally {
|
|
656
|
+
this.setState('disposed')
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
}
|
package/src/session.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { SharedCameraSession } from './session-core'
|
|
2
|
+
import type { BackendPreviewListener } from './session-core'
|
|
3
|
+
import { createSessionBackend } from './session-backend'
|
|
4
|
+
import { CameraCaptureError } from './types'
|
|
5
|
+
import type { CameraSession, CameraSessionConfig } from './types'
|
|
6
|
+
|
|
7
|
+
/** Create a shared camera owner: one acquisition shared between a
|
|
8
|
+
* `CameraView` preview (`session` prop) and at most one movie-recording
|
|
9
|
+
* attempt. Creating a session never prompts for permission or acquires
|
|
10
|
+
* hardware — both happen when a session-backed preview activates or a
|
|
11
|
+
* permission request runs.
|
|
12
|
+
*
|
|
13
|
+
* Recording support is delivered per platform; `capabilities()` reports
|
|
14
|
+
* honest `supported`/`available` state on every host. */
|
|
15
|
+
export function createCameraSession(config: CameraSessionConfig = {}): CameraSession {
|
|
16
|
+
return new SharedCameraSession(createSessionBackend(config), config)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Package-internal: attach `host` as the session's preview surface.
|
|
20
|
+
* Used by `CameraView` when its `session` prop is set; detaching the
|
|
21
|
+
* returned function requests stop on an in-flight attempt and releases
|
|
22
|
+
* camera ownership once finalization settles. */
|
|
23
|
+
export function attachSessionPreview(
|
|
24
|
+
session: CameraSession,
|
|
25
|
+
host: unknown,
|
|
26
|
+
listener?: BackendPreviewListener,
|
|
27
|
+
): () => void {
|
|
28
|
+
if (!(session instanceof SharedCameraSession)) {
|
|
29
|
+
throw new CameraCaptureError({
|
|
30
|
+
kind: 'invalidArgument',
|
|
31
|
+
operation: 'attachPreview',
|
|
32
|
+
message: 'session was not created by createCameraSession',
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return session.attach(host, listener)
|
|
37
|
+
}
|