@chipmobilesdk/rn-camera 0.2.1 → 0.4.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/CHANGELOG.md +122 -0
- package/README.md +64 -9
- package/package.json +10 -2
- package/skills/sdk-rn-camera/SKILL.md +21 -3
- package/src/config/types.ts +8 -0
- package/src/core/captureController.ts +11 -1
- package/src/core/contract.ts +109 -0
- package/src/core/controller.ts +117 -163
- package/src/core/sessionRuntime.ts +242 -0
- package/src/diagnostics/errors.ts +17 -15
- package/src/diagnostics/logger.ts +22 -1
- package/src/index.ts +3 -1
- package/src/native/cameraEngine.ts +35 -10
- package/src/native/visionCameraPeer.ts +30 -0
- package/src/native/visionSurface.ts +43 -18
- package/src/permissions/permissionPort.ts +24 -20
- package/src/react/CameraPreview.tsx +53 -9
- package/src/react/VisionCameraFeed.tsx +70 -10
- package/src/react/hooks.ts +1 -1
- package/src/storage/artifactStore.ts +9 -2
- package/src/testing/conformance.ts +140 -0
- package/src/testing/index.ts +27 -0
- package/src/testing/testCameraAdapter.ts +47 -35
package/src/core/controller.ts
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
* tested without a camera.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import { appLifecycle, createListenerSet } from '@chipmobilesdk/rn-core';
|
|
11
|
+
|
|
10
12
|
import {
|
|
11
13
|
type CameraPermissionState,
|
|
12
14
|
type CameraPosition,
|
|
@@ -18,10 +20,15 @@ import {
|
|
|
18
20
|
type StartOptions,
|
|
19
21
|
} from '../config/types';
|
|
20
22
|
import { CameraError, isCameraError } from '../diagnostics/errors';
|
|
21
|
-
import { silentLogger,
|
|
23
|
+
import { silentLogger, withSdkSinks } from '../diagnostics/logger';
|
|
22
24
|
import type { CameraEngine } from '../native/cameraEngine';
|
|
25
|
+
import {
|
|
26
|
+
DEFAULT_TEMPORARY_TTL_MS,
|
|
27
|
+
type AppStatus,
|
|
28
|
+
type CameraController,
|
|
29
|
+
type CameraControllerOptions,
|
|
30
|
+
} from './contract';
|
|
23
31
|
import type { PermissionPort } from '../permissions/permissionPort';
|
|
24
|
-
import type { ArtifactStore } from '../storage/artifactStore';
|
|
25
32
|
import { EMPTY_CAPABILITIES, assertSupported, supportsTorch } from './capabilities';
|
|
26
33
|
import {
|
|
27
34
|
SingleFlight,
|
|
@@ -30,72 +37,29 @@ import {
|
|
|
30
37
|
type CaptureContext,
|
|
31
38
|
} from './captureController';
|
|
32
39
|
import { canStart, initialSession, sessionReducer, type SessionSnapshot } from './sessionMachine';
|
|
40
|
+
import { createSessionRuntime } from './sessionRuntime';
|
|
33
41
|
|
|
34
42
|
/** 24 hours (FR-029). */
|
|
35
|
-
export
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
readonly permissions?: PermissionPort;
|
|
42
|
-
readonly store?: ArtifactStore;
|
|
43
|
-
readonly logger?: CameraLogger;
|
|
44
|
-
readonly temporaryTtlMs?: number;
|
|
45
|
-
/**
|
|
46
|
-
* App foreground/background source. Defaults to React Native `AppState`.
|
|
47
|
-
* Injectable so lifecycle rules are testable without an app (FR-015).
|
|
48
|
-
*/
|
|
49
|
-
readonly subscribeToAppState?: (listener: (status: AppStatus) => void) => () => void;
|
|
50
|
-
/**
|
|
51
|
-
* Set false to skip the sweep that runs when the controller is first used
|
|
52
|
-
* (tests). Defaults to true.
|
|
53
|
-
*/
|
|
54
|
-
readonly sweepOnInit?: boolean;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export interface CameraController {
|
|
58
|
-
readonly state: CameraState;
|
|
59
|
-
subscribe(listener: (state: CameraState) => void): () => void;
|
|
60
|
-
|
|
61
|
-
getPermission(): Promise<CameraPermissionState>;
|
|
62
|
-
requestPermission(): Promise<CameraPermissionState>;
|
|
63
|
-
|
|
64
|
-
start(options?: StartOptions): Promise<void>;
|
|
65
|
-
pause(): Promise<void>;
|
|
66
|
-
resume(): Promise<void>;
|
|
67
|
-
stop(): Promise<void>;
|
|
68
|
-
|
|
69
|
-
capture(request?: CaptureRequest): Promise<CapturedImage>;
|
|
70
|
-
cleanup(image: CapturedImage | string): Promise<void>;
|
|
71
|
-
|
|
72
|
-
getCapabilities(): Promise<CapabilityProfile>;
|
|
73
|
-
setFlashMode(mode: FlashMode): void;
|
|
74
|
-
setTorch(enabled: boolean): Promise<void>;
|
|
75
|
-
|
|
76
|
-
/** Marks the preview surface mounted, enabling foreground auto-restart. */
|
|
77
|
-
setSurfaceMounted(mounted: boolean): void;
|
|
78
|
-
/**
|
|
79
|
-
* Releases ports, listeners, and the camera. Safe to call twice. Afterwards
|
|
80
|
-
* `start`, `resume`, `capture` and the permission and capability reads reject
|
|
81
|
-
* with `notReady`; `stop`, `pause` and `cleanup` stay safe no-ops so teardown
|
|
82
|
-
* code never has to check.
|
|
83
|
-
*/
|
|
84
|
-
dispose(): Promise<void>;
|
|
85
|
-
}
|
|
43
|
+
export {
|
|
44
|
+
DEFAULT_TEMPORARY_TTL_MS,
|
|
45
|
+
type AppStatus,
|
|
46
|
+
type CameraController,
|
|
47
|
+
type CameraControllerOptions,
|
|
48
|
+
} from './contract';
|
|
86
49
|
|
|
87
50
|
function defaultAppStateSubscribe(listener: (status: AppStatus) => void): () => void {
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
const subscription = AppState.addEventListener('change', (next: string) => {
|
|
92
|
-
listener(next === 'active' ? 'active' : 'background');
|
|
93
|
-
});
|
|
94
|
-
return () => subscription.remove();
|
|
51
|
+
// rn-core's one lifecycle subscription, shared with every other package; it
|
|
52
|
+
// requires react-native only on first subscribe (PERF-07).
|
|
53
|
+
return appLifecycle().subscribe(state => listener(state === 'active' ? 'active' : 'background'));
|
|
95
54
|
}
|
|
96
55
|
|
|
97
|
-
|
|
98
|
-
|
|
56
|
+
/** 8 s: a cold camera on a slow device, with room to spare (PERF-001 is 1.5 s). */
|
|
57
|
+
export const DEFAULT_START_TIMEOUT_MS = 8_000;
|
|
58
|
+
|
|
59
|
+
export function createCameraController(options: CameraControllerOptions): CameraController {
|
|
60
|
+
if (!options?.engine || !options?.permissions) {
|
|
61
|
+
// A configuration fault, named as one. The ports are required by the type;
|
|
62
|
+
// this is the answer for a JavaScript caller or a cast.
|
|
99
63
|
throw new CameraError(
|
|
100
64
|
'unsupportedCapability',
|
|
101
65
|
'createCameraController requires `engine` and `permissions` ports. Use the ' +
|
|
@@ -103,13 +67,18 @@ export function createCameraController(options: CameraControllerOptions = {}): C
|
|
|
103
67
|
);
|
|
104
68
|
}
|
|
105
69
|
|
|
106
|
-
// Bound after the guard so the closures below see non-optional ports.
|
|
107
70
|
const engine: CameraEngine = options.engine;
|
|
108
71
|
const permissions: PermissionPort = options.permissions;
|
|
109
72
|
const store = options.store;
|
|
110
|
-
const logger = options.logger ?? silentLogger;
|
|
73
|
+
const logger = withSdkSinks(options.logger ?? silentLogger);
|
|
111
74
|
const ttlMs = options.temporaryTtlMs ?? DEFAULT_TEMPORARY_TTL_MS;
|
|
112
75
|
|
|
76
|
+
const startTimeoutMs = options.startTimeoutMs ?? DEFAULT_START_TIMEOUT_MS;
|
|
77
|
+
const scheduler = options.scheduler ?? {
|
|
78
|
+
setTimeout: (callback: () => void, ms: number) => setTimeout(callback, ms),
|
|
79
|
+
clearTimeout: (handle: unknown) => clearTimeout(handle as ReturnType<typeof setTimeout>),
|
|
80
|
+
};
|
|
81
|
+
|
|
113
82
|
let session: SessionSnapshot = initialSession;
|
|
114
83
|
let permission: CameraPermissionState = 'undetermined';
|
|
115
84
|
let capabilities: CapabilityProfile | null = null;
|
|
@@ -118,18 +87,40 @@ export function createCameraController(options: CameraControllerOptions = {}): C
|
|
|
118
87
|
let torchEnabled = false;
|
|
119
88
|
let surfaceMounted = false;
|
|
120
89
|
let disposed = false;
|
|
121
|
-
let appStatus: AppStatus = 'active';
|
|
122
|
-
// Set only when backgrounding cut a running session short. Foregrounding
|
|
123
|
-
// used to restart from any idle or stopped session, so a camera the user had
|
|
124
|
-
// stopped — or never started, or no longer had permission for — came back on
|
|
125
|
-
// with the app.
|
|
126
|
-
let resumeOnForeground = false;
|
|
127
|
-
let releasing: Promise<void> | null = null;
|
|
128
90
|
let warnedUnmanaged = false;
|
|
129
91
|
let detach: (() => void) | null = null;
|
|
130
92
|
|
|
131
93
|
const capture = new SingleFlight();
|
|
132
|
-
const listeners =
|
|
94
|
+
const listeners = createListenerSet<[CameraState]>({
|
|
95
|
+
onListenerError: () => logger.log({ type: 'session', code: 'listener-threw' }),
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The session's own I/O (`sessionRuntime`): starting it, waiting for frames,
|
|
100
|
+
* releasing it, following the app in and out of the foreground. It reads and
|
|
101
|
+
* writes the state above through this context, so one snapshot reaches
|
|
102
|
+
* subscribers whichever part changed it.
|
|
103
|
+
*/
|
|
104
|
+
const runtime = createSessionRuntime({
|
|
105
|
+
engine,
|
|
106
|
+
logger,
|
|
107
|
+
startTimeoutMs,
|
|
108
|
+
scheduler,
|
|
109
|
+
session: () => session,
|
|
110
|
+
dispatch,
|
|
111
|
+
emit,
|
|
112
|
+
cameraPosition: () => cameraPosition,
|
|
113
|
+
setCameraPosition: position => {
|
|
114
|
+
cameraPosition = position;
|
|
115
|
+
},
|
|
116
|
+
torchEnabled: () => torchEnabled,
|
|
117
|
+
setTorchEnabled: enabled => {
|
|
118
|
+
torchEnabled = enabled;
|
|
119
|
+
},
|
|
120
|
+
surfaceMounted: () => surfaceMounted,
|
|
121
|
+
permissionGranted: () => permission === 'granted',
|
|
122
|
+
disposed: () => disposed,
|
|
123
|
+
});
|
|
133
124
|
|
|
134
125
|
function snapshot(): CameraState {
|
|
135
126
|
return {
|
|
@@ -140,6 +131,9 @@ export function createCameraController(options: CameraControllerOptions = {}): C
|
|
|
140
131
|
flashMode,
|
|
141
132
|
torchEnabled,
|
|
142
133
|
capturing: capture.busy,
|
|
134
|
+
// What the mounted preview turned out to be, once it reported: the
|
|
135
|
+
// format's pixel size and the orientation coordinate mapping needs.
|
|
136
|
+
surface: runtime.surfaceInfo(),
|
|
143
137
|
error: session.error
|
|
144
138
|
? {
|
|
145
139
|
code: session.error.code,
|
|
@@ -154,15 +148,9 @@ export function createCameraController(options: CameraControllerOptions = {}): C
|
|
|
154
148
|
|
|
155
149
|
function emit(): void {
|
|
156
150
|
current = snapshot();
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
try {
|
|
161
|
-
listener(current);
|
|
162
|
-
} catch {
|
|
163
|
-
logger.log({ type: 'session', code: 'listener-threw' });
|
|
164
|
-
}
|
|
165
|
-
}
|
|
151
|
+
// Isolated: one throwing subscriber neither starves the others nor unwinds
|
|
152
|
+
// into the transition that emitted, which left the controller half-updated.
|
|
153
|
+
listeners.notify(current);
|
|
166
154
|
}
|
|
167
155
|
|
|
168
156
|
function dispatch(event: Parameters<typeof sessionReducer>[1]): void {
|
|
@@ -178,72 +166,6 @@ export function createCameraController(options: CameraControllerOptions = {}): C
|
|
|
178
166
|
}
|
|
179
167
|
}
|
|
180
168
|
|
|
181
|
-
/** Torch must never survive leaving the active state (FR-034b). */
|
|
182
|
-
async function forceTorchOff(): Promise<void> {
|
|
183
|
-
if (!torchEnabled) {
|
|
184
|
-
return;
|
|
185
|
-
}
|
|
186
|
-
torchEnabled = false;
|
|
187
|
-
try {
|
|
188
|
-
await engine.setTorch(false);
|
|
189
|
-
} catch {
|
|
190
|
-
// Torch-off is best effort; the session is going away regardless.
|
|
191
|
-
}
|
|
192
|
-
emit();
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
async function releaseCamera(): Promise<void> {
|
|
196
|
-
await forceTorchOff();
|
|
197
|
-
// Nothing to release. Going through STOP/STOPPED here turned an idle
|
|
198
|
-
// controller into `stopped` on every preview unmount.
|
|
199
|
-
if (session.status === 'idle' || session.status === 'stopped') {
|
|
200
|
-
return;
|
|
201
|
-
}
|
|
202
|
-
dispatch({ type: 'STOP' });
|
|
203
|
-
try {
|
|
204
|
-
await engine.stop();
|
|
205
|
-
dispatch({ type: 'STOPPED' });
|
|
206
|
-
} catch (error) {
|
|
207
|
-
dispatch({ type: 'FAIL', code: isCameraError(error) ? error.code : 'captureFailed' });
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
async function startEngine(startOptions: StartOptions = {}): Promise<void> {
|
|
212
|
-
try {
|
|
213
|
-
await engine.start({ cameraPosition: startOptions.cameraPosition ?? cameraPosition });
|
|
214
|
-
dispatch({ type: 'STARTED' });
|
|
215
|
-
} catch (error) {
|
|
216
|
-
dispatch({ type: 'FAIL', code: isCameraError(error) ? error.code : 'cameraUnavailable' });
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
function resumeAfterForeground(): void {
|
|
221
|
-
if (disposed || appStatus !== 'active' || !resumeOnForeground) {
|
|
222
|
-
return;
|
|
223
|
-
}
|
|
224
|
-
resumeOnForeground = false;
|
|
225
|
-
if (!surfaceMounted || permission !== 'granted') {
|
|
226
|
-
return;
|
|
227
|
-
}
|
|
228
|
-
dispatch({ type: 'FOREGROUND', surfaceMounted });
|
|
229
|
-
if (session.status === 'starting') {
|
|
230
|
-
startEngine().catch(() => undefined);
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
function onAppState(status: AppStatus): void {
|
|
235
|
-
appStatus = status;
|
|
236
|
-
if (status === 'background') {
|
|
237
|
-
resumeOnForeground ||= session.status === 'active' || session.status === 'starting';
|
|
238
|
-
dispatch({ type: 'BACKGROUND' });
|
|
239
|
-
releasing = releaseCamera().catch(() => undefined);
|
|
240
|
-
} else {
|
|
241
|
-
// Wait for a release still in flight, or the restart lands on a
|
|
242
|
-
// `stopping` session and is dropped.
|
|
243
|
-
(releasing ?? Promise.resolve()).then(resumeAfterForeground).catch(() => undefined);
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
|
|
247
169
|
/**
|
|
248
170
|
* Subscribes to permission and app-state changes and runs the sweep, on first
|
|
249
171
|
* use rather than at construction. React may build a controller during a
|
|
@@ -261,18 +183,23 @@ export function createCameraController(options: CameraControllerOptions = {}): C
|
|
|
261
183
|
// A revocation while running must tear the session down, not leave a
|
|
262
184
|
// frozen preview on screen (FR-007).
|
|
263
185
|
if (next !== 'granted') {
|
|
264
|
-
|
|
186
|
+
runtime.armResumeOnForeground();
|
|
265
187
|
if (session.status !== 'idle' && session.status !== 'stopped') {
|
|
266
|
-
|
|
188
|
+
runtime.release().catch(() => undefined);
|
|
267
189
|
}
|
|
268
190
|
}
|
|
269
191
|
});
|
|
270
|
-
const unsubscribeAppState = (options.subscribeToAppState ?? defaultAppStateSubscribe)(
|
|
271
|
-
onAppState,
|
|
192
|
+
const unsubscribeAppState = (options.subscribeToAppState ?? defaultAppStateSubscribe)(status =>
|
|
193
|
+
runtime.onAppState(status),
|
|
272
194
|
);
|
|
195
|
+
// The OS can take the camera at any moment — another app, an incoming
|
|
196
|
+
// call, a disconnected device. The session has to go with it (CAM-19).
|
|
197
|
+
const unsubscribeInterruptions =
|
|
198
|
+
engine.subscribeToInterruptions?.(code => runtime.onInterruption(code)) ?? (() => undefined);
|
|
273
199
|
detach = () => {
|
|
274
200
|
unsubscribePermission();
|
|
275
201
|
unsubscribeAppState();
|
|
202
|
+
unsubscribeInterruptions();
|
|
276
203
|
};
|
|
277
204
|
|
|
278
205
|
// FR-029: sweep our own temp directory once, asynchronously, so it can
|
|
@@ -336,10 +263,7 @@ export function createCameraController(options: CameraControllerOptions = {}): C
|
|
|
336
263
|
return () => undefined;
|
|
337
264
|
}
|
|
338
265
|
attach();
|
|
339
|
-
listeners.add(listener);
|
|
340
|
-
return () => {
|
|
341
|
-
listeners.delete(listener);
|
|
342
|
-
};
|
|
266
|
+
return listeners.add(listener);
|
|
343
267
|
},
|
|
344
268
|
|
|
345
269
|
async getPermission() {
|
|
@@ -353,7 +277,17 @@ export function createCameraController(options: CameraControllerOptions = {}): C
|
|
|
353
277
|
live('requestPermission');
|
|
354
278
|
permission = 'requesting';
|
|
355
279
|
emit();
|
|
356
|
-
|
|
280
|
+
try {
|
|
281
|
+
permission = await permissions.request();
|
|
282
|
+
} catch (error) {
|
|
283
|
+
// A native call that failed used to leave the state at `requesting`
|
|
284
|
+
// forever, with no way for the app to ask again (CAM-07).
|
|
285
|
+
permission = await permissions.getStatus().catch(() => 'unavailable' as const);
|
|
286
|
+
emit();
|
|
287
|
+
throw isCameraError(error)
|
|
288
|
+
? error
|
|
289
|
+
: new CameraError('permissionDenied', 'The permission request could not be completed');
|
|
290
|
+
}
|
|
357
291
|
emit();
|
|
358
292
|
return permission;
|
|
359
293
|
},
|
|
@@ -371,7 +305,7 @@ export function createCameraController(options: CameraControllerOptions = {}): C
|
|
|
371
305
|
cameraPosition = startOptions.cameraPosition;
|
|
372
306
|
}
|
|
373
307
|
dispatch({ type: 'START' });
|
|
374
|
-
await
|
|
308
|
+
await runtime.start(startOptions);
|
|
375
309
|
if (session.error) {
|
|
376
310
|
throw session.error;
|
|
377
311
|
}
|
|
@@ -381,7 +315,7 @@ export function createCameraController(options: CameraControllerOptions = {}): C
|
|
|
381
315
|
if (disposed) {
|
|
382
316
|
return;
|
|
383
317
|
}
|
|
384
|
-
await forceTorchOff();
|
|
318
|
+
await runtime.forceTorchOff();
|
|
385
319
|
dispatch({ type: 'PAUSE' });
|
|
386
320
|
},
|
|
387
321
|
|
|
@@ -389,17 +323,17 @@ export function createCameraController(options: CameraControllerOptions = {}): C
|
|
|
389
323
|
live('resume');
|
|
390
324
|
dispatch({ type: 'RESUME' });
|
|
391
325
|
if (session.status === 'starting') {
|
|
392
|
-
await
|
|
326
|
+
await runtime.start();
|
|
393
327
|
}
|
|
394
328
|
},
|
|
395
329
|
|
|
396
330
|
async stop() {
|
|
397
331
|
// An explicit stop is the user's decision; foregrounding must not undo it.
|
|
398
|
-
|
|
332
|
+
runtime.armResumeOnForeground();
|
|
399
333
|
if (disposed) {
|
|
400
334
|
return;
|
|
401
335
|
}
|
|
402
|
-
await
|
|
336
|
+
await runtime.release();
|
|
403
337
|
},
|
|
404
338
|
|
|
405
339
|
async capture(request: CaptureRequest = {}) {
|
|
@@ -492,6 +426,26 @@ export function createCameraController(options: CameraControllerOptions = {}): C
|
|
|
492
426
|
emit();
|
|
493
427
|
},
|
|
494
428
|
|
|
429
|
+
reportSurfaceUnavailable() {
|
|
430
|
+
if (disposed) {
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
logger.log({ type: 'session', code: 'surface-unavailable', recoverable: false });
|
|
434
|
+
dispatch({ type: 'FAIL', code: 'cameraUnavailable' });
|
|
435
|
+
},
|
|
436
|
+
|
|
437
|
+
attachSurface(surface) {
|
|
438
|
+
return engine.attachSurface?.(surface) ?? (() => undefined);
|
|
439
|
+
},
|
|
440
|
+
|
|
441
|
+
reportSurfaceStarted(info) {
|
|
442
|
+
runtime.reportSurfaceStarted(info);
|
|
443
|
+
},
|
|
444
|
+
|
|
445
|
+
reportSurfaceFailed(code) {
|
|
446
|
+
runtime.reportSurfaceFailed(code);
|
|
447
|
+
},
|
|
448
|
+
|
|
495
449
|
setSurfaceMounted(mounted: boolean) {
|
|
496
450
|
if (disposed) {
|
|
497
451
|
return;
|
|
@@ -504,10 +458,10 @@ export function createCameraController(options: CameraControllerOptions = {}): C
|
|
|
504
458
|
return;
|
|
505
459
|
}
|
|
506
460
|
disposed = true;
|
|
507
|
-
|
|
461
|
+
runtime.armResumeOnForeground();
|
|
508
462
|
detach?.();
|
|
509
463
|
detach = null;
|
|
510
|
-
await
|
|
464
|
+
await runtime.release();
|
|
511
465
|
listeners.clear();
|
|
512
466
|
},
|
|
513
467
|
};
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The session's own I/O: starting it, waiting for frames, releasing it, and
|
|
3
|
+
* following the app in and out of the foreground.
|
|
4
|
+
*
|
|
5
|
+
* Split out of `controller.ts`, which had grown to hold three jobs at once:
|
|
6
|
+
* publishing state, running the session, and serving the public API. The
|
|
7
|
+
* decisions still live in `sessionMachine` (pure); this is the part that
|
|
8
|
+
* actually awaits the engine, and the controller composes it.
|
|
9
|
+
*
|
|
10
|
+
* It reads and writes the controller's state through `SessionContext` rather
|
|
11
|
+
* than owning it: one snapshot reaches subscribers, whichever part of the
|
|
12
|
+
* controller changed it.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { StartOptions } from '../config/types';
|
|
16
|
+
import { isCameraError, type CameraErrorCode } from '../diagnostics/errors';
|
|
17
|
+
import type { CameraLogger } from '../diagnostics/logger';
|
|
18
|
+
import type { CameraEngine } from '../native/cameraEngine';
|
|
19
|
+
import type { SurfaceInfo } from '../native/visionSurface';
|
|
20
|
+
import type { SessionEvent, SessionSnapshot } from './sessionMachine';
|
|
21
|
+
|
|
22
|
+
/** What the runtime needs from the controller that owns the state. */
|
|
23
|
+
export interface SessionContext {
|
|
24
|
+
readonly engine: CameraEngine;
|
|
25
|
+
readonly logger: CameraLogger;
|
|
26
|
+
/** How long to wait for a preview surface to report frames. */
|
|
27
|
+
readonly startTimeoutMs: number;
|
|
28
|
+
readonly scheduler: {
|
|
29
|
+
setTimeout(callback: () => void, ms: number): unknown;
|
|
30
|
+
clearTimeout(handle: unknown): void;
|
|
31
|
+
};
|
|
32
|
+
session(): SessionSnapshot;
|
|
33
|
+
dispatch(event: SessionEvent): void;
|
|
34
|
+
emit(): void;
|
|
35
|
+
cameraPosition(): 'front' | 'back';
|
|
36
|
+
setCameraPosition(position: 'front' | 'back'): void;
|
|
37
|
+
torchEnabled(): boolean;
|
|
38
|
+
setTorchEnabled(enabled: boolean): void;
|
|
39
|
+
surfaceMounted(): boolean;
|
|
40
|
+
permissionGranted(): boolean;
|
|
41
|
+
disposed(): boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface SessionRuntime {
|
|
45
|
+
/** Starts the engine and, where the surface owns the session, waits for frames. */
|
|
46
|
+
start(options?: StartOptions): Promise<void>;
|
|
47
|
+
/** Turns the torch off and releases the camera. Safe from any state. */
|
|
48
|
+
release(): Promise<void>;
|
|
49
|
+
/** Torch off, whatever the session is doing. */
|
|
50
|
+
forceTorchOff(): Promise<void>;
|
|
51
|
+
/** The app went to the background or came back. */
|
|
52
|
+
onAppState(status: 'active' | 'background'): void;
|
|
53
|
+
/** The preview reported frames; carries what the mounted device turned out to be. */
|
|
54
|
+
reportSurfaceStarted(info: SurfaceInfo): void;
|
|
55
|
+
/** The preview's own session failed, or the OS took the camera. */
|
|
56
|
+
reportSurfaceFailed(code: CameraErrorCode): void;
|
|
57
|
+
/** The engine lost the camera: another app, a call, a disconnected device. */
|
|
58
|
+
onInterruption(code: CameraErrorCode): void;
|
|
59
|
+
/** What the surface last reported, for the published state. */
|
|
60
|
+
surfaceInfo(): SurfaceInfo | null;
|
|
61
|
+
/** Set when the app foregrounds with a surface mounted, so the session restarts. */
|
|
62
|
+
armResumeOnForeground(): void;
|
|
63
|
+
/** A release still in flight, so a restart can wait for it. */
|
|
64
|
+
pending(): Promise<void> | undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function createSessionRuntime(context: SessionContext): SessionRuntime {
|
|
68
|
+
let surfaceInfo: SurfaceInfo | null = null;
|
|
69
|
+
/** Resolved by the surface's own report, or by the start timeout. */
|
|
70
|
+
let pendingStart: { settle(): void; fail(code: CameraErrorCode): void } | null = null;
|
|
71
|
+
let appStatus: 'active' | 'background' = 'active';
|
|
72
|
+
let resumeOnForeground = false;
|
|
73
|
+
let releasing: Promise<void> | undefined;
|
|
74
|
+
|
|
75
|
+
/** Torch must never survive leaving the active state (FR-034b). */
|
|
76
|
+
async function forceTorchOff(): Promise<void> {
|
|
77
|
+
if (!context.torchEnabled()) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
context.setTorchEnabled(false);
|
|
81
|
+
try {
|
|
82
|
+
await context.engine.setTorch(false);
|
|
83
|
+
} catch {
|
|
84
|
+
// Torch-off is best effort; the session is going away regardless.
|
|
85
|
+
}
|
|
86
|
+
context.emit();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function release(): Promise<void> {
|
|
90
|
+
await forceTorchOff();
|
|
91
|
+
// Nothing to release. Going through STOP/STOPPED here turned an idle
|
|
92
|
+
// controller into `stopped` on every preview unmount.
|
|
93
|
+
const status = context.session().status;
|
|
94
|
+
if (status === 'idle' || status === 'stopped') {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
context.dispatch({ type: 'STOP' });
|
|
98
|
+
try {
|
|
99
|
+
await context.engine.stop();
|
|
100
|
+
context.dispatch({ type: 'STOPPED' });
|
|
101
|
+
} catch (error) {
|
|
102
|
+
context.dispatch({
|
|
103
|
+
type: 'FAIL',
|
|
104
|
+
code: isCameraError(error) ? error.code : 'captureFailed',
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Waits for the mounted preview to report frames.
|
|
111
|
+
*
|
|
112
|
+
* `engine.start()` only confirms a device exists for the VisionCamera
|
|
113
|
+
* adapter: the session belongs to the `<Camera>` view. Calling the session
|
|
114
|
+
* active there made `capture()` right after `start()` fail with `notReady`,
|
|
115
|
+
* and a camera that never produced a frame left the session in `starting`
|
|
116
|
+
* forever (CAM-08).
|
|
117
|
+
*/
|
|
118
|
+
function awaitSurfaceStart(): Promise<void> {
|
|
119
|
+
return new Promise<void>(resolve => {
|
|
120
|
+
const timer = context.scheduler.setTimeout(() => {
|
|
121
|
+
if (pendingStart) {
|
|
122
|
+
pendingStart = null;
|
|
123
|
+
context.dispatch({ type: 'START_TIMEOUT' });
|
|
124
|
+
resolve();
|
|
125
|
+
}
|
|
126
|
+
}, context.startTimeoutMs);
|
|
127
|
+
const finish = () => {
|
|
128
|
+
context.scheduler.clearTimeout(timer);
|
|
129
|
+
pendingStart = null;
|
|
130
|
+
resolve();
|
|
131
|
+
};
|
|
132
|
+
pendingStart = {
|
|
133
|
+
settle: () => {
|
|
134
|
+
context.dispatch({ type: 'STARTED' });
|
|
135
|
+
finish();
|
|
136
|
+
},
|
|
137
|
+
fail: code => {
|
|
138
|
+
context.dispatch({ type: 'FAIL', code });
|
|
139
|
+
finish();
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function start(options: StartOptions = {}): Promise<void> {
|
|
146
|
+
try {
|
|
147
|
+
await context.engine.start({
|
|
148
|
+
cameraPosition: options.cameraPosition ?? context.cameraPosition(),
|
|
149
|
+
});
|
|
150
|
+
} catch (error) {
|
|
151
|
+
context.dispatch({
|
|
152
|
+
type: 'FAIL',
|
|
153
|
+
code: isCameraError(error) ? error.code : 'cameraUnavailable',
|
|
154
|
+
});
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (context.engine.awaitsSurfaceStart !== true || !context.surfaceMounted()) {
|
|
158
|
+
// The engine owns its session, or there is no preview to wait for.
|
|
159
|
+
context.dispatch({ type: 'STARTED' });
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
await awaitSurfaceStart();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function resumeAfterForeground(): void {
|
|
166
|
+
if (context.disposed() || appStatus !== 'active' || !resumeOnForeground) {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
resumeOnForeground = false;
|
|
170
|
+
if (!context.surfaceMounted() || !context.permissionGranted()) {
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
context.dispatch({ type: 'FOREGROUND', surfaceMounted: true });
|
|
174
|
+
if (context.session().status === 'starting') {
|
|
175
|
+
start().catch(() => undefined);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
start,
|
|
181
|
+
release,
|
|
182
|
+
forceTorchOff,
|
|
183
|
+
|
|
184
|
+
onAppState(status) {
|
|
185
|
+
appStatus = status;
|
|
186
|
+
if (status === 'background') {
|
|
187
|
+
const running = context.session().status;
|
|
188
|
+
resumeOnForeground ||= running === 'active' || running === 'starting';
|
|
189
|
+
context.dispatch({ type: 'BACKGROUND' });
|
|
190
|
+
releasing = release().catch(() => undefined);
|
|
191
|
+
} else {
|
|
192
|
+
// Wait for a release still in flight, or the restart lands on a
|
|
193
|
+
// `stopping` session and is dropped.
|
|
194
|
+
(releasing ?? Promise.resolve()).then(resumeAfterForeground).catch(() => undefined);
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
|
|
198
|
+
reportSurfaceStarted(info) {
|
|
199
|
+
surfaceInfo = info;
|
|
200
|
+
if (info.cameraPosition !== context.cameraPosition()) {
|
|
201
|
+
// The device that mounted is the one taking the photos.
|
|
202
|
+
context.setCameraPosition(info.cameraPosition);
|
|
203
|
+
}
|
|
204
|
+
if (pendingStart) {
|
|
205
|
+
pendingStart.settle();
|
|
206
|
+
} else if (context.session().status === 'starting') {
|
|
207
|
+
context.dispatch({ type: 'STARTED' });
|
|
208
|
+
} else {
|
|
209
|
+
context.emit();
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
|
|
213
|
+
reportSurfaceFailed(code) {
|
|
214
|
+
if (pendingStart) {
|
|
215
|
+
pendingStart.fail(code);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
context.dispatch({ type: 'FAIL', code });
|
|
219
|
+
},
|
|
220
|
+
|
|
221
|
+
onInterruption(code) {
|
|
222
|
+
context.logger.log({ type: 'session', code });
|
|
223
|
+
if (pendingStart) {
|
|
224
|
+
pendingStart.fail(code);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
const status = context.session().status;
|
|
228
|
+
if (status !== 'idle' && status !== 'stopped') {
|
|
229
|
+
context.dispatch({ type: 'FAIL', code });
|
|
230
|
+
release().catch(() => undefined);
|
|
231
|
+
}
|
|
232
|
+
},
|
|
233
|
+
|
|
234
|
+
surfaceInfo: () => surfaceInfo,
|
|
235
|
+
|
|
236
|
+
armResumeOnForeground() {
|
|
237
|
+
resumeOnForeground = false;
|
|
238
|
+
},
|
|
239
|
+
|
|
240
|
+
pending: () => releasing,
|
|
241
|
+
};
|
|
242
|
+
}
|