@chipmobilesdk/rn-camera 0.3.0 → 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 +22 -0
- package/README.md +36 -7
- package/package.json +1 -1
- package/skills/sdk-rn-camera/SKILL.md +19 -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 +96 -148
- package/src/core/sessionRuntime.ts +242 -0
- package/src/index.ts +3 -1
- package/src/native/cameraEngine.ts +28 -8
- package/src/native/visionSurface.ts +43 -18
- package/src/react/CameraPreview.tsx +40 -7
- package/src/react/VisionCameraFeed.tsx +61 -5
- package/src/react/hooks.ts +1 -1
- package/src/storage/artifactStore.ts +9 -2
- package/src/testing/testCameraAdapter.ts +29 -7
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# @chipmobilesdk/rn-camera
|
|
2
2
|
|
|
3
|
+
## 0.4.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- `start()` resolves when the preview is producing frames. With the VisionCamera adapter the session belongs to the `<Camera>` view, so the controller now waits for that view to report instead of calling the session active as soon as the request was made — `capture()` straight after `start()` used to fail with `notReady`. A camera that never produces a frame fails the start after `startTimeoutMs` (8 s by default) rather than leaving the session in `starting` forever, and the native session errors the preview reports map to `cameraInUse`, `cameraBlocked`, `permissionDenied`, `cameraUnavailable` or `interrupted`. ([#19](https://github.com/operationd8979/chip-mobile-sdk/pull/19))
|
|
8
|
+
|
|
9
|
+
`pause()` keeps the preview surface mounted and stops the camera, so `resume()` no longer rebuilds the device, the session and the photo output — the whole first-frame cost was paid on every resume, with a black flash to go with it.
|
|
10
|
+
|
|
11
|
+
Geometry comes from the camera. `onGeometryChange` fires again with the selected format's pixel size, the device's orientation and the position of the device that actually mounted, as soon as the surface reports them; the same values are published as `state.surface`. Mapping a rectangle on a portrait phone was being done against a nominal landscape 1920×1080 image.
|
|
12
|
+
|
|
13
|
+
`capture({ cameraPosition })` must name the camera that is running, and is refused with `notReady` otherwise: the photo came from the device the preview had mounted while the metadata claimed the camera that was asked for. `start({ cameraPosition })` switches.
|
|
14
|
+
|
|
15
|
+
A failed `requestPermission()` leaves a state the app can retry from rather than `requesting` forever. An engine can report that the camera was taken away (`subscribeToInterruptions`), and the session fails with that code instead of showing a frozen preview; `createTestCamera()` simulates it with `emitInterruption(code?)` and a failing permission request with `emitPermissionRequestFailure(true)`.
|
|
16
|
+
|
|
17
|
+
The preview surface belongs to its engine (`attachSurface`, `createSurfaceRegistry`), not to a module-level slot two controllers shared — a capture from one screen could run through another screen's surface. `engine` and `permissions` are required by `CameraControllerOptions` (as they always were at runtime), the unused `EngineOptions` type is gone, and the artifact store reports a typed `CameraError` rather than a bare `Error`.
|
|
18
|
+
|
|
19
|
+
### Patch Changes
|
|
20
|
+
|
|
21
|
+
- Internal: no module in these packages is over 500 lines any more. The camera controller's session I/O moved to `sessionRuntime` and its contract to `contract.ts`; the Play adapter's shape translation to `iapShapes`; the purchase flow's failure mapping and its receipt-validation hook to modules of their own; the AI client's quota request to `quotaRequest`; the in-memory adapter's attachments and the fake ads engine's clock likewise. Behaviour is unchanged — the splits are by responsibility, and every package's suite passes as it stood. ([#19](https://github.com/operationd8979/chip-mobile-sdk/pull/19))
|
|
22
|
+
|
|
23
|
+
The repository also measures what it promised: `npm run perf` prints the median of nine runs of the deterministic hot paths against written budgets (recurrence expansion, theme construction, translation lookup, chart geometry, filter compilation), the demo app has a Performance screen reporting startup phases, time to interactive and the same benchmarks on Hermes, and `npm run check:file-size` keeps the limit from drifting. The budgets and the measured figures are recorded in `docs/standards.md` §VI.
|
|
24
|
+
|
|
3
25
|
## 0.3.0
|
|
4
26
|
|
|
5
27
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -213,11 +213,39 @@ const inImageSpace = mapRectToImage(myGuideFrame, geometry);
|
|
|
213
213
|
```
|
|
214
214
|
|
|
215
215
|
The pure mapping functions account for scale, letterbox or crop, orientation,
|
|
216
|
-
and front-camera mirroring using the geometry you supply.
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
216
|
+
and front-camera mirroring using the geometry you supply.
|
|
217
|
+
|
|
218
|
+
`onGeometryChange` fires first with nominal dimensions (`1920 × 1080`,
|
|
219
|
+
orientation `0`) because the preview has been measured before the camera has,
|
|
220
|
+
and again with the real ones as soon as the surface reports: the selected
|
|
221
|
+
format's pixel size, the device's orientation, and the position of the device
|
|
222
|
+
that actually mounted. The same values are on `state.surface`, which is `null`
|
|
223
|
+
until then — map coordinates once it is set, or a rectangle will be mapped
|
|
224
|
+
against a landscape image on a portrait phone.
|
|
225
|
+
|
|
226
|
+
### Starting, pausing, and what "active" means
|
|
227
|
+
|
|
228
|
+
`start()` resolves when the preview is producing frames, not when the request
|
|
229
|
+
was made: with the VisionCamera adapter the session belongs to the `<Camera>`
|
|
230
|
+
view, so the controller waits for it to report and `capture()` straight after
|
|
231
|
+
`start()` works. A camera that never produces a frame fails the start after
|
|
232
|
+
`startTimeoutMs` (8 s by default) instead of leaving the session in `starting`.
|
|
233
|
+
|
|
234
|
+
`pause()` keeps the surface mounted and stops the camera; `resume()` therefore
|
|
235
|
+
does not rebuild the device, the session and the photo output. The surface is
|
|
236
|
+
unmounted only when the session stops, fails, or loses permission.
|
|
237
|
+
|
|
238
|
+
An engine can report that the camera was taken away — another app, an incoming
|
|
239
|
+
call, a disconnected device — through `subscribeToInterruptions`. The session
|
|
240
|
+
fails with that code rather than showing a frozen preview; `createTestCamera()`
|
|
241
|
+
simulates it with `emitInterruption(code?)`.
|
|
242
|
+
|
|
243
|
+
### Which camera a capture comes from
|
|
244
|
+
|
|
245
|
+
`capture({ cameraPosition })` must name the camera that is running: a photo
|
|
246
|
+
comes from the device the preview mounted, so a request for the other one is
|
|
247
|
+
refused with `notReady` rather than returning an image labelled with a camera
|
|
248
|
+
that did not take it. Call `start({ cameraPosition })` to switch first.
|
|
221
249
|
|
|
222
250
|
### Quality and dimensions
|
|
223
251
|
|
|
@@ -359,8 +387,9 @@ Everything is exported from `@chipmobilesdk/rn-camera`, test doubles and conform
|
|
|
359
387
|
`CameraState`, `AppStatus`, `DEFAULT_TEMPORARY_TTL_MS`
|
|
360
388
|
- Preview and capture: `CameraPreview`, `CameraPreviewProps`, `CaptureRequest`,
|
|
361
389
|
`CapturedImage`, `StartOptions` and the camera/session/capability types
|
|
362
|
-
- Ports and adapters: `CameraEngine`, `
|
|
363
|
-
`
|
|
390
|
+
- Ports and adapters: `CameraEngine`, `createVisionCameraEngine`,
|
|
391
|
+
`VisionSurface`, `SurfaceInfo`, `SurfaceRegistry`, `createSurfaceRegistry`,
|
|
392
|
+
`PermissionPort`,
|
|
364
393
|
`createVisionCameraPermissions`, `ArtifactStore`, `FileSystemBridge`,
|
|
365
394
|
`StoreOptions`, `createCacheArtifactStore`, `createArtifactStore`
|
|
366
395
|
- Mapping: `mapPointToImage`, `mapPointToPreview`, `mapRectToImage`,
|
package/package.json
CHANGED
|
@@ -56,9 +56,25 @@ Runtime:
|
|
|
56
56
|
- Conformance (`@chipmobilesdk/rn-camera/testing`): `runPermissionPortConformance`,
|
|
57
57
|
`runCameraEngineConformance` — run against any adapter you write or inject
|
|
58
58
|
|
|
59
|
-
Types: `CameraEngine`, `
|
|
60
|
-
`
|
|
61
|
-
`CameraLogEventType`, `CameraLogger`
|
|
59
|
+
Types: `CameraEngine`, `PermissionPort`, `ArtifactStore`, `FileSystemBridge`,
|
|
60
|
+
`StoreOptions`, `VisionSurface`, `SurfaceInfo`, `SurfaceRegistry`,
|
|
61
|
+
`CameraErrorCode`, `CameraLogEvent`, `CameraLogEventType`, `CameraLogger`
|
|
62
|
+
|
|
63
|
+
## Three things the lifecycle guarantees
|
|
64
|
+
|
|
65
|
+
- **`start()` resolves when frames arrive.** `capture()` straight after it works, and a
|
|
66
|
+
camera that never produces a frame fails the start (8 s by default) instead of leaving
|
|
67
|
+
the session in `starting`.
|
|
68
|
+
- **`pause()` keeps the surface mounted** and stops the camera, so `resume()` is cheap.
|
|
69
|
+
- **`capture({ cameraPosition })` must name the running camera.** A photo comes from the
|
|
70
|
+
device the preview mounted; asking for the other one is `notReady`. Switch with
|
|
71
|
+
`start({ cameraPosition })`.
|
|
72
|
+
|
|
73
|
+
## Coordinate mapping waits for the surface
|
|
74
|
+
|
|
75
|
+
`state.surface` is `null` until the preview reports its real format, and
|
|
76
|
+
`onGeometryChange` fires again with those numbers. Map rectangles after it is set —
|
|
77
|
+
before that the geometry is a nominal landscape 1920×1080.
|
|
62
78
|
|
|
63
79
|
Always pass camera log payloads through `redact` before logging.
|
|
64
80
|
|
package/src/config/types.ts
CHANGED
|
@@ -21,6 +21,8 @@ export type FlashMode = 'auto' | 'on' | 'off';
|
|
|
21
21
|
export type ResizeMode = 'cover' | 'contain';
|
|
22
22
|
export type ArtifactOwnership = 'packageTemporary' | 'appOwned';
|
|
23
23
|
|
|
24
|
+
import type { SurfaceInfo } from '../native/visionSurface';
|
|
25
|
+
|
|
24
26
|
export type SessionStatus =
|
|
25
27
|
'idle' | 'starting' | 'active' | 'paused' | 'stopping' | 'stopped' | 'error';
|
|
26
28
|
|
|
@@ -129,6 +131,12 @@ export interface CameraState {
|
|
|
129
131
|
readonly flashMode: FlashMode;
|
|
130
132
|
readonly torchEnabled: boolean;
|
|
131
133
|
readonly capturing: boolean;
|
|
134
|
+
/**
|
|
135
|
+
* What the mounted preview surface reported about itself: the capture
|
|
136
|
+
* dimensions of the selected format, its orientation, and the position of
|
|
137
|
+
* the device actually running. `null` until a surface reports (CAM-10).
|
|
138
|
+
*/
|
|
139
|
+
readonly surface: SurfaceInfo | null;
|
|
132
140
|
readonly error: CameraErrorLike | null;
|
|
133
141
|
}
|
|
134
142
|
|
|
@@ -67,6 +67,15 @@ export function assertCaptureAllowed(context: CaptureContext, request: CaptureRe
|
|
|
67
67
|
|
|
68
68
|
const position = request.cameraPosition ?? context.cameraPosition;
|
|
69
69
|
assertSupported(supportsPosition(context.capabilities, position), `${position} camera`);
|
|
70
|
+
if (position !== context.cameraPosition) {
|
|
71
|
+
// The photo would come from the camera that is running, while the
|
|
72
|
+
// metadata said it came from the one that was asked for (CAM-06).
|
|
73
|
+
throw new CameraError(
|
|
74
|
+
'notReady',
|
|
75
|
+
`The ${context.cameraPosition} camera is running. Call start({ cameraPosition: ` +
|
|
76
|
+
`'${position}' }) before capturing with the ${position} camera.`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
70
79
|
}
|
|
71
80
|
|
|
72
81
|
/**
|
|
@@ -83,7 +92,8 @@ export function finalizeResult(
|
|
|
83
92
|
return {
|
|
84
93
|
...raw,
|
|
85
94
|
ownership,
|
|
86
|
-
|
|
95
|
+
// The camera that took it, which the guard above has made the running one.
|
|
96
|
+
cameraPosition: raw.cameraPosition ?? context.cameraPosition,
|
|
87
97
|
timestamp: raw.timestamp ?? Date.now(),
|
|
88
98
|
effectiveQuality: raw.effectiveQuality ?? clampQuality(request.quality),
|
|
89
99
|
effectiveMaxDimension: raw.effectiveMaxDimension ?? clampMaxDimension(request.maxDimension),
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a camera controller is, for the app that drives it.
|
|
3
|
+
*
|
|
4
|
+
* The contract lives apart from the orchestration in `controller.ts`: it is
|
|
5
|
+
* what a consumer reads, and what a test double has to satisfy, while the file
|
|
6
|
+
* next to it is how this package happens to satisfy it.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type {
|
|
10
|
+
CameraPermissionState,
|
|
11
|
+
CameraState,
|
|
12
|
+
CapabilityProfile,
|
|
13
|
+
CapturedImage,
|
|
14
|
+
CaptureRequest,
|
|
15
|
+
FlashMode,
|
|
16
|
+
StartOptions,
|
|
17
|
+
} from '../config/types';
|
|
18
|
+
import type { CameraError } from '../diagnostics/errors';
|
|
19
|
+
import type { CameraLogger } from '../diagnostics/logger';
|
|
20
|
+
import type { CameraEngine } from '../native/cameraEngine';
|
|
21
|
+
import type { SurfaceInfo, VisionSurface } from '../native/visionSurface';
|
|
22
|
+
import type { PermissionPort } from '../permissions/permissionPort';
|
|
23
|
+
import type { ArtifactStore } from '../storage/artifactStore';
|
|
24
|
+
|
|
25
|
+
export const DEFAULT_TEMPORARY_TTL_MS = 24 * 60 * 60 * 1000;
|
|
26
|
+
|
|
27
|
+
export type AppStatus = 'active' | 'background';
|
|
28
|
+
|
|
29
|
+
export interface CameraControllerOptions {
|
|
30
|
+
/** Required. Use the default adapters, or `createTestCamera()` in tests. */
|
|
31
|
+
readonly engine: CameraEngine;
|
|
32
|
+
readonly permissions: PermissionPort;
|
|
33
|
+
readonly store?: ArtifactStore | undefined;
|
|
34
|
+
readonly logger?: CameraLogger | undefined;
|
|
35
|
+
readonly temporaryTtlMs?: number;
|
|
36
|
+
/**
|
|
37
|
+
* App foreground/background source. Defaults to rn-core's shared `appLifecycle()`,
|
|
38
|
+
* where `inactive` counts as background. Injectable so lifecycle rules are
|
|
39
|
+
* testable without an app (FR-015).
|
|
40
|
+
*/
|
|
41
|
+
readonly subscribeToAppState?: (listener: (status: AppStatus) => void) => () => void;
|
|
42
|
+
/**
|
|
43
|
+
* Set false to skip the sweep that runs when the controller is first used
|
|
44
|
+
* (tests). Defaults to true.
|
|
45
|
+
*/
|
|
46
|
+
readonly sweepOnInit?: boolean;
|
|
47
|
+
/**
|
|
48
|
+
* How long to wait for a preview surface to report frames before failing the
|
|
49
|
+
* start (FR-014). Only applies to an engine whose session belongs to the
|
|
50
|
+
* surface (`awaitsSurfaceStart`). Defaults to 8 s.
|
|
51
|
+
*/
|
|
52
|
+
readonly startTimeoutMs?: number;
|
|
53
|
+
/** Test seam for that timeout. */
|
|
54
|
+
readonly scheduler?: {
|
|
55
|
+
setTimeout(callback: () => void, ms: number): unknown;
|
|
56
|
+
clearTimeout(handle: unknown): void;
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface CameraController {
|
|
61
|
+
readonly state: CameraState;
|
|
62
|
+
subscribe(listener: (state: CameraState) => void): () => void;
|
|
63
|
+
|
|
64
|
+
getPermission(): Promise<CameraPermissionState>;
|
|
65
|
+
requestPermission(): Promise<CameraPermissionState>;
|
|
66
|
+
|
|
67
|
+
start(options?: StartOptions): Promise<void>;
|
|
68
|
+
pause(): Promise<void>;
|
|
69
|
+
resume(): Promise<void>;
|
|
70
|
+
stop(): Promise<void>;
|
|
71
|
+
|
|
72
|
+
capture(request?: CaptureRequest): Promise<CapturedImage>;
|
|
73
|
+
cleanup(image: CapturedImage | string): Promise<void>;
|
|
74
|
+
|
|
75
|
+
getCapabilities(): Promise<CapabilityProfile>;
|
|
76
|
+
setFlashMode(mode: FlashMode): void;
|
|
77
|
+
setTorch(enabled: boolean): Promise<void>;
|
|
78
|
+
|
|
79
|
+
/** Marks the preview surface mounted, enabling foreground auto-restart. */
|
|
80
|
+
setSurfaceMounted(mounted: boolean): void;
|
|
81
|
+
/**
|
|
82
|
+
* Attaches the mounted preview surface to the engine, for an engine that
|
|
83
|
+
* captures through it. Returns the detach function; a no-op for engines that
|
|
84
|
+
* own their own session.
|
|
85
|
+
*/
|
|
86
|
+
attachSurface(surface: VisionSurface): () => void;
|
|
87
|
+
/**
|
|
88
|
+
* The preview reported frames: the session is active for real, not merely
|
|
89
|
+
* requested (CAM-08). What the surface turned out to be — the format's pixel
|
|
90
|
+
* size, the orientation, the device's own position — is published as
|
|
91
|
+
* `state.surface` for coordinate mapping.
|
|
92
|
+
*/
|
|
93
|
+
reportSurfaceStarted(info: SurfaceInfo): void;
|
|
94
|
+
/** The preview's own session failed, or the OS took the camera away. */
|
|
95
|
+
reportSurfaceFailed(code: CameraError['code']): void;
|
|
96
|
+
/**
|
|
97
|
+
* Called by `CameraPreview` when the native preview surface cannot load (the
|
|
98
|
+
* VisionCamera module is missing or unlinked). Fails the session with
|
|
99
|
+
* `cameraUnavailable` rather than leaving a black preview and no error.
|
|
100
|
+
*/
|
|
101
|
+
reportSurfaceUnavailable(): void;
|
|
102
|
+
/**
|
|
103
|
+
* Releases ports, listeners, and the camera. Safe to call twice. Afterwards
|
|
104
|
+
* `start`, `resume`, `capture` and the permission and capability reads reject
|
|
105
|
+
* with `notReady`; `stop`, `pause` and `cleanup` stay safe no-ops so teardown
|
|
106
|
+
* code never has to check.
|
|
107
|
+
*/
|
|
108
|
+
dispose(): Promise<void>;
|
|
109
|
+
}
|
package/src/core/controller.ts
CHANGED
|
@@ -20,10 +20,15 @@ import {
|
|
|
20
20
|
type StartOptions,
|
|
21
21
|
} from '../config/types';
|
|
22
22
|
import { CameraError, isCameraError } from '../diagnostics/errors';
|
|
23
|
-
import { silentLogger, withSdkSinks
|
|
23
|
+
import { silentLogger, withSdkSinks } from '../diagnostics/logger';
|
|
24
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';
|
|
25
31
|
import type { PermissionPort } from '../permissions/permissionPort';
|
|
26
|
-
import type { ArtifactStore } from '../storage/artifactStore';
|
|
27
32
|
import { EMPTY_CAPABILITIES, assertSupported, supportsTorch } from './capabilities';
|
|
28
33
|
import {
|
|
29
34
|
SingleFlight,
|
|
@@ -32,66 +37,15 @@ import {
|
|
|
32
37
|
type CaptureContext,
|
|
33
38
|
} from './captureController';
|
|
34
39
|
import { canStart, initialSession, sessionReducer, type SessionSnapshot } from './sessionMachine';
|
|
40
|
+
import { createSessionRuntime } from './sessionRuntime';
|
|
35
41
|
|
|
36
42
|
/** 24 hours (FR-029). */
|
|
37
|
-
export
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
readonly permissions?: PermissionPort;
|
|
44
|
-
readonly store?: ArtifactStore | undefined;
|
|
45
|
-
readonly logger?: CameraLogger | undefined;
|
|
46
|
-
readonly temporaryTtlMs?: number;
|
|
47
|
-
/**
|
|
48
|
-
* App foreground/background source. Defaults to rn-core's shared `appLifecycle()`,
|
|
49
|
-
* where `inactive` counts as background. Injectable so lifecycle rules are
|
|
50
|
-
* testable without an app (FR-015).
|
|
51
|
-
*/
|
|
52
|
-
readonly subscribeToAppState?: (listener: (status: AppStatus) => void) => () => void;
|
|
53
|
-
/**
|
|
54
|
-
* Set false to skip the sweep that runs when the controller is first used
|
|
55
|
-
* (tests). Defaults to true.
|
|
56
|
-
*/
|
|
57
|
-
readonly sweepOnInit?: boolean;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export interface CameraController {
|
|
61
|
-
readonly state: CameraState;
|
|
62
|
-
subscribe(listener: (state: CameraState) => void): () => void;
|
|
63
|
-
|
|
64
|
-
getPermission(): Promise<CameraPermissionState>;
|
|
65
|
-
requestPermission(): Promise<CameraPermissionState>;
|
|
66
|
-
|
|
67
|
-
start(options?: StartOptions): Promise<void>;
|
|
68
|
-
pause(): Promise<void>;
|
|
69
|
-
resume(): Promise<void>;
|
|
70
|
-
stop(): Promise<void>;
|
|
71
|
-
|
|
72
|
-
capture(request?: CaptureRequest): Promise<CapturedImage>;
|
|
73
|
-
cleanup(image: CapturedImage | string): Promise<void>;
|
|
74
|
-
|
|
75
|
-
getCapabilities(): Promise<CapabilityProfile>;
|
|
76
|
-
setFlashMode(mode: FlashMode): void;
|
|
77
|
-
setTorch(enabled: boolean): Promise<void>;
|
|
78
|
-
|
|
79
|
-
/** Marks the preview surface mounted, enabling foreground auto-restart. */
|
|
80
|
-
setSurfaceMounted(mounted: boolean): void;
|
|
81
|
-
/**
|
|
82
|
-
* Called by `CameraPreview` when the native preview surface cannot load (the
|
|
83
|
-
* VisionCamera module is missing or unlinked). Fails the session with
|
|
84
|
-
* `cameraUnavailable` rather than leaving a black preview and no error.
|
|
85
|
-
*/
|
|
86
|
-
reportSurfaceUnavailable(): void;
|
|
87
|
-
/**
|
|
88
|
-
* Releases ports, listeners, and the camera. Safe to call twice. Afterwards
|
|
89
|
-
* `start`, `resume`, `capture` and the permission and capability reads reject
|
|
90
|
-
* with `notReady`; `stop`, `pause` and `cleanup` stay safe no-ops so teardown
|
|
91
|
-
* code never has to check.
|
|
92
|
-
*/
|
|
93
|
-
dispose(): Promise<void>;
|
|
94
|
-
}
|
|
43
|
+
export {
|
|
44
|
+
DEFAULT_TEMPORARY_TTL_MS,
|
|
45
|
+
type AppStatus,
|
|
46
|
+
type CameraController,
|
|
47
|
+
type CameraControllerOptions,
|
|
48
|
+
} from './contract';
|
|
95
49
|
|
|
96
50
|
function defaultAppStateSubscribe(listener: (status: AppStatus) => void): () => void {
|
|
97
51
|
// rn-core's one lifecycle subscription, shared with every other package; it
|
|
@@ -99,8 +53,13 @@ function defaultAppStateSubscribe(listener: (status: AppStatus) => void): () =>
|
|
|
99
53
|
return appLifecycle().subscribe(state => listener(state === 'active' ? 'active' : 'background'));
|
|
100
54
|
}
|
|
101
55
|
|
|
102
|
-
|
|
103
|
-
|
|
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.
|
|
104
63
|
throw new CameraError(
|
|
105
64
|
'unsupportedCapability',
|
|
106
65
|
'createCameraController requires `engine` and `permissions` ports. Use the ' +
|
|
@@ -108,13 +67,18 @@ export function createCameraController(options: CameraControllerOptions = {}): C
|
|
|
108
67
|
);
|
|
109
68
|
}
|
|
110
69
|
|
|
111
|
-
// Bound after the guard so the closures below see non-optional ports.
|
|
112
70
|
const engine: CameraEngine = options.engine;
|
|
113
71
|
const permissions: PermissionPort = options.permissions;
|
|
114
72
|
const store = options.store;
|
|
115
73
|
const logger = withSdkSinks(options.logger ?? silentLogger);
|
|
116
74
|
const ttlMs = options.temporaryTtlMs ?? DEFAULT_TEMPORARY_TTL_MS;
|
|
117
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
|
+
|
|
118
82
|
let session: SessionSnapshot = initialSession;
|
|
119
83
|
let permission: CameraPermissionState = 'undetermined';
|
|
120
84
|
let capabilities: CapabilityProfile | null = null;
|
|
@@ -123,13 +87,6 @@ export function createCameraController(options: CameraControllerOptions = {}): C
|
|
|
123
87
|
let torchEnabled = false;
|
|
124
88
|
let surfaceMounted = false;
|
|
125
89
|
let disposed = false;
|
|
126
|
-
let appStatus: AppStatus = 'active';
|
|
127
|
-
// Set only when backgrounding cut a running session short. Foregrounding
|
|
128
|
-
// used to restart from any idle or stopped session, so a camera the user had
|
|
129
|
-
// stopped — or never started, or no longer had permission for — came back on
|
|
130
|
-
// with the app.
|
|
131
|
-
let resumeOnForeground = false;
|
|
132
|
-
let releasing: Promise<void> | null = null;
|
|
133
90
|
let warnedUnmanaged = false;
|
|
134
91
|
let detach: (() => void) | null = null;
|
|
135
92
|
|
|
@@ -138,6 +95,33 @@ export function createCameraController(options: CameraControllerOptions = {}): C
|
|
|
138
95
|
onListenerError: () => logger.log({ type: 'session', code: 'listener-threw' }),
|
|
139
96
|
});
|
|
140
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
|
+
});
|
|
124
|
+
|
|
141
125
|
function snapshot(): CameraState {
|
|
142
126
|
return {
|
|
143
127
|
session: session.status,
|
|
@@ -147,6 +131,9 @@ export function createCameraController(options: CameraControllerOptions = {}): C
|
|
|
147
131
|
flashMode,
|
|
148
132
|
torchEnabled,
|
|
149
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(),
|
|
150
137
|
error: session.error
|
|
151
138
|
? {
|
|
152
139
|
code: session.error.code,
|
|
@@ -179,72 +166,6 @@ export function createCameraController(options: CameraControllerOptions = {}): C
|
|
|
179
166
|
}
|
|
180
167
|
}
|
|
181
168
|
|
|
182
|
-
/** Torch must never survive leaving the active state (FR-034b). */
|
|
183
|
-
async function forceTorchOff(): Promise<void> {
|
|
184
|
-
if (!torchEnabled) {
|
|
185
|
-
return;
|
|
186
|
-
}
|
|
187
|
-
torchEnabled = false;
|
|
188
|
-
try {
|
|
189
|
-
await engine.setTorch(false);
|
|
190
|
-
} catch {
|
|
191
|
-
// Torch-off is best effort; the session is going away regardless.
|
|
192
|
-
}
|
|
193
|
-
emit();
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
async function releaseCamera(): Promise<void> {
|
|
197
|
-
await forceTorchOff();
|
|
198
|
-
// Nothing to release. Going through STOP/STOPPED here turned an idle
|
|
199
|
-
// controller into `stopped` on every preview unmount.
|
|
200
|
-
if (session.status === 'idle' || session.status === 'stopped') {
|
|
201
|
-
return;
|
|
202
|
-
}
|
|
203
|
-
dispatch({ type: 'STOP' });
|
|
204
|
-
try {
|
|
205
|
-
await engine.stop();
|
|
206
|
-
dispatch({ type: 'STOPPED' });
|
|
207
|
-
} catch (error) {
|
|
208
|
-
dispatch({ type: 'FAIL', code: isCameraError(error) ? error.code : 'captureFailed' });
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
async function startEngine(startOptions: StartOptions = {}): Promise<void> {
|
|
213
|
-
try {
|
|
214
|
-
await engine.start({ cameraPosition: startOptions.cameraPosition ?? cameraPosition });
|
|
215
|
-
dispatch({ type: 'STARTED' });
|
|
216
|
-
} catch (error) {
|
|
217
|
-
dispatch({ type: 'FAIL', code: isCameraError(error) ? error.code : 'cameraUnavailable' });
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
function resumeAfterForeground(): void {
|
|
222
|
-
if (disposed || appStatus !== 'active' || !resumeOnForeground) {
|
|
223
|
-
return;
|
|
224
|
-
}
|
|
225
|
-
resumeOnForeground = false;
|
|
226
|
-
if (!surfaceMounted || permission !== 'granted') {
|
|
227
|
-
return;
|
|
228
|
-
}
|
|
229
|
-
dispatch({ type: 'FOREGROUND', surfaceMounted });
|
|
230
|
-
if (session.status === 'starting') {
|
|
231
|
-
startEngine().catch(() => undefined);
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
function onAppState(status: AppStatus): void {
|
|
236
|
-
appStatus = status;
|
|
237
|
-
if (status === 'background') {
|
|
238
|
-
resumeOnForeground ||= session.status === 'active' || session.status === 'starting';
|
|
239
|
-
dispatch({ type: 'BACKGROUND' });
|
|
240
|
-
releasing = releaseCamera().catch(() => undefined);
|
|
241
|
-
} else {
|
|
242
|
-
// Wait for a release still in flight, or the restart lands on a
|
|
243
|
-
// `stopping` session and is dropped.
|
|
244
|
-
(releasing ?? Promise.resolve()).then(resumeAfterForeground).catch(() => undefined);
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
|
|
248
169
|
/**
|
|
249
170
|
* Subscribes to permission and app-state changes and runs the sweep, on first
|
|
250
171
|
* use rather than at construction. React may build a controller during a
|
|
@@ -262,18 +183,23 @@ export function createCameraController(options: CameraControllerOptions = {}): C
|
|
|
262
183
|
// A revocation while running must tear the session down, not leave a
|
|
263
184
|
// frozen preview on screen (FR-007).
|
|
264
185
|
if (next !== 'granted') {
|
|
265
|
-
|
|
186
|
+
runtime.armResumeOnForeground();
|
|
266
187
|
if (session.status !== 'idle' && session.status !== 'stopped') {
|
|
267
|
-
|
|
188
|
+
runtime.release().catch(() => undefined);
|
|
268
189
|
}
|
|
269
190
|
}
|
|
270
191
|
});
|
|
271
|
-
const unsubscribeAppState = (options.subscribeToAppState ?? defaultAppStateSubscribe)(
|
|
272
|
-
onAppState,
|
|
192
|
+
const unsubscribeAppState = (options.subscribeToAppState ?? defaultAppStateSubscribe)(status =>
|
|
193
|
+
runtime.onAppState(status),
|
|
273
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);
|
|
274
199
|
detach = () => {
|
|
275
200
|
unsubscribePermission();
|
|
276
201
|
unsubscribeAppState();
|
|
202
|
+
unsubscribeInterruptions();
|
|
277
203
|
};
|
|
278
204
|
|
|
279
205
|
// FR-029: sweep our own temp directory once, asynchronously, so it can
|
|
@@ -351,7 +277,17 @@ export function createCameraController(options: CameraControllerOptions = {}): C
|
|
|
351
277
|
live('requestPermission');
|
|
352
278
|
permission = 'requesting';
|
|
353
279
|
emit();
|
|
354
|
-
|
|
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
|
+
}
|
|
355
291
|
emit();
|
|
356
292
|
return permission;
|
|
357
293
|
},
|
|
@@ -369,7 +305,7 @@ export function createCameraController(options: CameraControllerOptions = {}): C
|
|
|
369
305
|
cameraPosition = startOptions.cameraPosition;
|
|
370
306
|
}
|
|
371
307
|
dispatch({ type: 'START' });
|
|
372
|
-
await
|
|
308
|
+
await runtime.start(startOptions);
|
|
373
309
|
if (session.error) {
|
|
374
310
|
throw session.error;
|
|
375
311
|
}
|
|
@@ -379,7 +315,7 @@ export function createCameraController(options: CameraControllerOptions = {}): C
|
|
|
379
315
|
if (disposed) {
|
|
380
316
|
return;
|
|
381
317
|
}
|
|
382
|
-
await forceTorchOff();
|
|
318
|
+
await runtime.forceTorchOff();
|
|
383
319
|
dispatch({ type: 'PAUSE' });
|
|
384
320
|
},
|
|
385
321
|
|
|
@@ -387,17 +323,17 @@ export function createCameraController(options: CameraControllerOptions = {}): C
|
|
|
387
323
|
live('resume');
|
|
388
324
|
dispatch({ type: 'RESUME' });
|
|
389
325
|
if (session.status === 'starting') {
|
|
390
|
-
await
|
|
326
|
+
await runtime.start();
|
|
391
327
|
}
|
|
392
328
|
},
|
|
393
329
|
|
|
394
330
|
async stop() {
|
|
395
331
|
// An explicit stop is the user's decision; foregrounding must not undo it.
|
|
396
|
-
|
|
332
|
+
runtime.armResumeOnForeground();
|
|
397
333
|
if (disposed) {
|
|
398
334
|
return;
|
|
399
335
|
}
|
|
400
|
-
await
|
|
336
|
+
await runtime.release();
|
|
401
337
|
},
|
|
402
338
|
|
|
403
339
|
async capture(request: CaptureRequest = {}) {
|
|
@@ -498,6 +434,18 @@ export function createCameraController(options: CameraControllerOptions = {}): C
|
|
|
498
434
|
dispatch({ type: 'FAIL', code: 'cameraUnavailable' });
|
|
499
435
|
},
|
|
500
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
|
+
|
|
501
449
|
setSurfaceMounted(mounted: boolean) {
|
|
502
450
|
if (disposed) {
|
|
503
451
|
return;
|
|
@@ -510,10 +458,10 @@ export function createCameraController(options: CameraControllerOptions = {}): C
|
|
|
510
458
|
return;
|
|
511
459
|
}
|
|
512
460
|
disposed = true;
|
|
513
|
-
|
|
461
|
+
runtime.armResumeOnForeground();
|
|
514
462
|
detach?.();
|
|
515
463
|
detach = null;
|
|
516
|
-
await
|
|
464
|
+
await runtime.release();
|
|
517
465
|
listeners.clear();
|
|
518
466
|
},
|
|
519
467
|
};
|