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