@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
|
@@ -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
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -39,7 +39,9 @@ export { createCameraController, DEFAULT_TEMPORARY_TTL_MS } from './core/control
|
|
|
39
39
|
export type { AppStatus, CameraController, CameraControllerOptions } from './core/controller';
|
|
40
40
|
|
|
41
41
|
// Ports + default adapters
|
|
42
|
-
export type { CameraEngine
|
|
42
|
+
export type { CameraEngine } from './native/cameraEngine';
|
|
43
|
+
export type { SurfaceInfo, SurfaceRegistry, VisionSurface } from './native/visionSurface';
|
|
44
|
+
export { createSurfaceRegistry } from './native/visionSurface';
|
|
43
45
|
export { createVisionCameraEngine } from './native/cameraEngine';
|
|
44
46
|
export type { PermissionPort } from './permissions/permissionPort';
|
|
45
47
|
export { createVisionCameraPermissions } from './permissions/permissionPort';
|
|
@@ -21,8 +21,9 @@ import type {
|
|
|
21
21
|
CaptureRequest,
|
|
22
22
|
StartOptions,
|
|
23
23
|
} from '../config/types';
|
|
24
|
+
import type { CameraErrorCode } from '../diagnostics/errors';
|
|
24
25
|
import { CameraError } from '../diagnostics/errors';
|
|
25
|
-
import {
|
|
26
|
+
import { createSurfaceRegistry, type VisionSurface } from './visionSurface';
|
|
26
27
|
|
|
27
28
|
export interface CameraEngine {
|
|
28
29
|
start(options: StartOptions): Promise<void>;
|
|
@@ -30,11 +31,24 @@ export interface CameraEngine {
|
|
|
30
31
|
capture(request: CaptureRequest): Promise<CapturedImage>;
|
|
31
32
|
setTorch(enabled: boolean): Promise<void>;
|
|
32
33
|
getCapabilities(): Promise<CapabilityProfile>;
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
34
|
+
/**
|
|
35
|
+
* True when `start()` only prepares the engine and the session becomes
|
|
36
|
+
* active once a mounted preview surface reports frames. The controller then
|
|
37
|
+
* waits for that report instead of calling the session active immediately,
|
|
38
|
+
* and times out rather than sitting in `starting` forever (CAM-08).
|
|
39
|
+
*/
|
|
40
|
+
readonly awaitsSurfaceStart?: boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Attaches the mounted preview surface this engine captures through.
|
|
43
|
+
* Returns the detach function. Engines that own their own session omit it.
|
|
44
|
+
*/
|
|
45
|
+
attachSurface?(surface: VisionSurface): () => void;
|
|
46
|
+
/**
|
|
47
|
+
* The engine lost the camera: another app took it, a call came in, the
|
|
48
|
+
* device was disconnected. The controller tears the session down with this
|
|
49
|
+
* code rather than leaving a frozen preview (CAM-19).
|
|
50
|
+
*/
|
|
51
|
+
subscribeToInterruptions?(listener: (code: CameraErrorCode) => void): () => void;
|
|
38
52
|
}
|
|
39
53
|
|
|
40
54
|
/** The subset of VisionCamera's `CameraDevice` this adapter reads. */
|
|
@@ -49,7 +63,9 @@ interface DiscoveredDevice {
|
|
|
49
63
|
* component, so this adapter owns capability discovery while `CameraPreview`
|
|
50
64
|
* owns the rendered surface and the photo output that capture runs through.
|
|
51
65
|
*/
|
|
52
|
-
export function createVisionCameraEngine(
|
|
66
|
+
export function createVisionCameraEngine(): CameraEngine {
|
|
67
|
+
// One registry per engine, so two controllers never share a surface.
|
|
68
|
+
const surfaces = createSurfaceRegistry();
|
|
53
69
|
// Loaded when the default adapter is created; a missing peer is `cameraUnavailable`.
|
|
54
70
|
const { VisionCamera } = loadVisionCamera<{
|
|
55
71
|
VisionCamera: {
|
|
@@ -67,6 +83,10 @@ export function createVisionCameraEngine(_options: EngineOptions = {}): CameraEn
|
|
|
67
83
|
}
|
|
68
84
|
|
|
69
85
|
return {
|
|
86
|
+
awaitsSurfaceStart: true,
|
|
87
|
+
|
|
88
|
+
attachSurface: surface => surfaces.attach(surface),
|
|
89
|
+
|
|
70
90
|
async start() {
|
|
71
91
|
// The preview component owns session activation; the engine only needs
|
|
72
92
|
// to confirm a device exists before the surface mounts.
|
|
@@ -81,7 +101,7 @@ export function createVisionCameraEngine(_options: EngineOptions = {}): CameraEn
|
|
|
81
101
|
},
|
|
82
102
|
|
|
83
103
|
async capture(request: CaptureRequest) {
|
|
84
|
-
const surface =
|
|
104
|
+
const surface = surfaces.current();
|
|
85
105
|
if (!surface) {
|
|
86
106
|
throw new CameraError(
|
|
87
107
|
'notReady',
|
|
@@ -1,34 +1,59 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* engine adapter.
|
|
2
|
+
* The port between a mounted preview surface and an engine.
|
|
4
3
|
*
|
|
5
|
-
* VisionCamera v5 owns capture through the session
|
|
4
|
+
* VisionCamera v5 owns capture through the session the `<Camera>` view
|
|
6
5
|
* configures, so the photo output only exists while a surface is mounted. The
|
|
7
|
-
* engine is created
|
|
8
|
-
*
|
|
6
|
+
* engine is created before any surface exists, so the surface attaches itself
|
|
7
|
+
* to the engine and the engine reads it back at capture time.
|
|
9
8
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* The registry belongs to an engine instance, not to the module. A module-level
|
|
10
|
+
* slot meant two controllers — two screens in a stack — shared one, so a
|
|
11
|
+
* capture could run through the other screen's surface (CAM-20).
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import type { CameraErrorCode } from '../diagnostics/errors';
|
|
14
15
|
import type { CapturedImage, CaptureRequest } from '../config/types';
|
|
15
16
|
|
|
17
|
+
/** What the mounted preview actually is, once the device and format are known. */
|
|
18
|
+
export interface SurfaceInfo {
|
|
19
|
+
/** Capture dimensions of the selected format, in pixels. */
|
|
20
|
+
readonly imageWidth: number;
|
|
21
|
+
readonly imageHeight: number;
|
|
22
|
+
/** Clockwise degrees the image is rotated relative to the preview. */
|
|
23
|
+
readonly orientation: number;
|
|
24
|
+
/** The position of the device actually mounted, whatever was asked for. */
|
|
25
|
+
readonly cameraPosition: 'front' | 'back';
|
|
26
|
+
}
|
|
27
|
+
|
|
16
28
|
export interface VisionSurface {
|
|
17
29
|
capture(request: CaptureRequest): Promise<CapturedImage>;
|
|
18
30
|
}
|
|
19
31
|
|
|
20
|
-
|
|
32
|
+
/** What a surface reports back about its own session. */
|
|
33
|
+
export interface SurfaceEvents {
|
|
34
|
+
/** The preview is running: frames are arriving. */
|
|
35
|
+
onStarted(info: SurfaceInfo): void;
|
|
36
|
+
/** The session failed or was taken away (another app, a phone call). */
|
|
37
|
+
onFailed(code: CameraErrorCode): void;
|
|
38
|
+
}
|
|
21
39
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
if (active === surface) {
|
|
27
|
-
active = null;
|
|
28
|
-
}
|
|
29
|
-
};
|
|
40
|
+
export interface SurfaceRegistry {
|
|
41
|
+
/** Attaches the mounted surface. Returns the detach function. */
|
|
42
|
+
attach(surface: VisionSurface): () => void;
|
|
43
|
+
current(): VisionSurface | null;
|
|
30
44
|
}
|
|
31
45
|
|
|
32
|
-
export function
|
|
33
|
-
|
|
46
|
+
export function createSurfaceRegistry(): SurfaceRegistry {
|
|
47
|
+
let active: VisionSurface | null = null;
|
|
48
|
+
return {
|
|
49
|
+
attach(surface) {
|
|
50
|
+
active = surface;
|
|
51
|
+
return () => {
|
|
52
|
+
if (active === surface) {
|
|
53
|
+
active = null;
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
},
|
|
57
|
+
current: () => active,
|
|
58
|
+
};
|
|
34
59
|
}
|
|
@@ -18,6 +18,8 @@ import {
|
|
|
18
18
|
|
|
19
19
|
import type { PreviewGeometry, ResizeMode } from '../config/types';
|
|
20
20
|
import type { CameraController } from '../core/controller';
|
|
21
|
+
import type { CameraErrorCode } from '../diagnostics/errors';
|
|
22
|
+
import type { SurfaceInfo, VisionSurface } from '../native/visionSurface';
|
|
21
23
|
import { useCameraState } from './hooks';
|
|
22
24
|
import type { VisionCameraFeedProps } from './VisionCameraFeed';
|
|
23
25
|
|
|
@@ -36,7 +38,15 @@ export interface CameraPreviewProps {
|
|
|
36
38
|
/** The feed module failed to load; distinct from `null`, which means not requested. */
|
|
37
39
|
const FEED_UNAVAILABLE = 'unavailable' as const;
|
|
38
40
|
|
|
39
|
-
/**
|
|
41
|
+
/**
|
|
42
|
+
* Nominal capture dimensions, until the mounted surface reports its own.
|
|
43
|
+
*
|
|
44
|
+
* Geometry built on these was landscape on a portrait phone, so every mapped
|
|
45
|
+
* rectangle was wrong on a real device (CAM-10). `onGeometryChange` now fires
|
|
46
|
+
* again with the real numbers as soon as the preview reports them, and apps
|
|
47
|
+
* that map coordinates should wait for `state.surface` rather than the first
|
|
48
|
+
* geometry.
|
|
49
|
+
*/
|
|
40
50
|
const DEFAULT_IMAGE_WIDTH = 1920;
|
|
41
51
|
const DEFAULT_IMAGE_HEIGHT = 1080;
|
|
42
52
|
|
|
@@ -66,6 +76,19 @@ export const CameraPreview: React.FC<CameraPreviewProps> = ({
|
|
|
66
76
|
};
|
|
67
77
|
}, [controller]);
|
|
68
78
|
|
|
79
|
+
const attachSurface = useCallback(
|
|
80
|
+
(surface: VisionSurface) => controller.attachSurface(surface),
|
|
81
|
+
[controller],
|
|
82
|
+
);
|
|
83
|
+
const onSurfaceStarted = useCallback(
|
|
84
|
+
(info: SurfaceInfo) => controller.reportSurfaceStarted(info),
|
|
85
|
+
[controller],
|
|
86
|
+
);
|
|
87
|
+
const onSurfaceFailed = useCallback(
|
|
88
|
+
(code: CameraErrorCode) => controller.reportSurfaceFailed(code),
|
|
89
|
+
[controller],
|
|
90
|
+
);
|
|
91
|
+
|
|
69
92
|
const onLayout = useCallback((event: LayoutChangeEvent) => {
|
|
70
93
|
const { width, height } = event.nativeEvent.layout;
|
|
71
94
|
setLayout({ width, height });
|
|
@@ -75,16 +98,17 @@ export const CameraPreview: React.FC<CameraPreviewProps> = ({
|
|
|
75
98
|
if (!layout || layout.width === 0 || layout.height === 0) {
|
|
76
99
|
return null;
|
|
77
100
|
}
|
|
101
|
+
const surface = state.surface;
|
|
78
102
|
return {
|
|
79
|
-
imageWidth: DEFAULT_IMAGE_WIDTH,
|
|
80
|
-
imageHeight: DEFAULT_IMAGE_HEIGHT,
|
|
103
|
+
imageWidth: surface && surface.imageWidth > 0 ? surface.imageWidth : DEFAULT_IMAGE_WIDTH,
|
|
104
|
+
imageHeight: surface && surface.imageHeight > 0 ? surface.imageHeight : DEFAULT_IMAGE_HEIGHT,
|
|
81
105
|
previewWidth: layout.width,
|
|
82
106
|
previewHeight: layout.height,
|
|
83
107
|
resizeMode,
|
|
84
|
-
orientation: 0,
|
|
85
|
-
isMirrored: state.cameraPosition === 'front',
|
|
108
|
+
orientation: surface?.orientation ?? 0,
|
|
109
|
+
isMirrored: (surface?.cameraPosition ?? state.cameraPosition) === 'front',
|
|
86
110
|
};
|
|
87
|
-
}, [layout, resizeMode, state.cameraPosition]);
|
|
111
|
+
}, [layout, resizeMode, state.cameraPosition, state.surface]);
|
|
88
112
|
|
|
89
113
|
useEffect(() => {
|
|
90
114
|
if (geometry && onGeometryChange) {
|
|
@@ -92,7 +116,12 @@ export const CameraPreview: React.FC<CameraPreviewProps> = ({
|
|
|
92
116
|
}
|
|
93
117
|
}, [geometry, onGeometryChange]);
|
|
94
118
|
|
|
95
|
-
|
|
119
|
+
// Mounted while the session is coming up, running, or paused: the surface is
|
|
120
|
+
// what makes it come up at all, and unmounting it on a pause meant rebuilding
|
|
121
|
+
// the device, the session and the photo output on every resume (CAM-08, CAM-09).
|
|
122
|
+
const feedRequested =
|
|
123
|
+
state.permission === 'granted' &&
|
|
124
|
+
(state.session === 'starting' || state.session === 'active' || state.session === 'paused');
|
|
96
125
|
|
|
97
126
|
// Lazy require so importing this package never pulls in the native module
|
|
98
127
|
// for consumers that only use the pure utilities or the test adapter.
|
|
@@ -129,6 +158,10 @@ export const CameraPreview: React.FC<CameraPreviewProps> = ({
|
|
|
129
158
|
cameraPosition={state.cameraPosition}
|
|
130
159
|
resizeMode={resizeMode}
|
|
131
160
|
torchEnabled={state.torchEnabled}
|
|
161
|
+
isActive={state.session !== 'paused'}
|
|
162
|
+
attachSurface={attachSurface}
|
|
163
|
+
onStarted={onSurfaceStarted}
|
|
164
|
+
onFailed={onSurfaceFailed}
|
|
132
165
|
/>
|
|
133
166
|
) : null}
|
|
134
167
|
{children}
|
|
@@ -30,13 +30,21 @@ import {
|
|
|
30
30
|
type ResizeMode,
|
|
31
31
|
} from '../config/types';
|
|
32
32
|
import { DEFAULT_QUALITY } from '../core/captureController';
|
|
33
|
-
import { CameraError, isCameraError } from '../diagnostics/errors';
|
|
34
|
-
import {
|
|
33
|
+
import { CameraError, isCameraError, type CameraErrorCode } from '../diagnostics/errors';
|
|
34
|
+
import type { SurfaceInfo, VisionSurface } from '../native/visionSurface';
|
|
35
35
|
|
|
36
36
|
export interface VisionCameraFeedProps {
|
|
37
37
|
readonly cameraPosition: CameraPosition;
|
|
38
38
|
readonly resizeMode: ResizeMode;
|
|
39
39
|
readonly torchEnabled: boolean;
|
|
40
|
+
/** False while the session is paused: the view stays mounted, the camera stops. */
|
|
41
|
+
readonly isActive: boolean;
|
|
42
|
+
/** Attaches this surface to the engine that captures through it. */
|
|
43
|
+
readonly attachSurface: (surface: VisionSurface) => () => void;
|
|
44
|
+
/** The preview is running; carries what the mounted device turned out to be. */
|
|
45
|
+
readonly onStarted: (info: SurfaceInfo) => void;
|
|
46
|
+
/** The session failed or the camera was taken away. */
|
|
47
|
+
readonly onFailed: (code: CameraErrorCode) => void;
|
|
40
48
|
}
|
|
41
49
|
|
|
42
50
|
const styles = StyleSheet.create({
|
|
@@ -71,6 +79,20 @@ function toFilePath(uri: string): string {
|
|
|
71
79
|
return uri.startsWith('file://') ? uri.slice('file://'.length) : uri;
|
|
72
80
|
}
|
|
73
81
|
|
|
82
|
+
/**
|
|
83
|
+
* What a native session error means, from the code VisionCamera reports
|
|
84
|
+
* (CAM-08). Anything unrecognised is an interruption: the session is gone, and
|
|
85
|
+
* the app may try again.
|
|
86
|
+
*/
|
|
87
|
+
function nativeSessionCode(error: unknown): CameraErrorCode {
|
|
88
|
+
const code = String((error as { code?: unknown })?.code ?? '').toLowerCase();
|
|
89
|
+
if (code.includes('permission')) return 'permissionDenied';
|
|
90
|
+
if (code.includes('in-use') || code.includes('already-in-use')) return 'cameraInUse';
|
|
91
|
+
if (code.includes('unavailable') || code.includes('no-device')) return 'cameraUnavailable';
|
|
92
|
+
if (code.includes('restricted') || code.includes('blocked')) return 'cameraBlocked';
|
|
93
|
+
return 'interrupted';
|
|
94
|
+
}
|
|
95
|
+
|
|
74
96
|
/** Native failures carry no code and may name a file path; never surface them raw. */
|
|
75
97
|
function asCameraError(error: unknown, code: 'captureFailed' | 'storageFailure'): CameraError {
|
|
76
98
|
return isCameraError(error) ? error : new CameraError(code);
|
|
@@ -79,6 +101,7 @@ function asCameraError(error: unknown, code: 'captureFailed' | 'storageFailure')
|
|
|
79
101
|
async function capturePhoto(
|
|
80
102
|
photoOutput: CameraPhotoOutput,
|
|
81
103
|
request: CaptureRequest,
|
|
104
|
+
/** The device actually mounted; the request cannot ask for another one. */
|
|
82
105
|
cameraPosition: CameraPosition,
|
|
83
106
|
): Promise<CapturedImage> {
|
|
84
107
|
let photo: Awaited<ReturnType<CameraPhotoOutput['capturePhoto']>>;
|
|
@@ -117,7 +140,8 @@ async function capturePhoto(
|
|
|
117
140
|
width: photo.width,
|
|
118
141
|
height: photo.height,
|
|
119
142
|
timestamp: Date.now(),
|
|
120
|
-
|
|
143
|
+
// The camera that took it, not the one the request named (CAM-06).
|
|
144
|
+
cameraPosition,
|
|
121
145
|
// Ownership is decided by the controller from the original request;
|
|
122
146
|
// this value is overwritten there and is only a safe default.
|
|
123
147
|
ownership: request.destination ? 'appOwned' : 'packageTemporary',
|
|
@@ -136,10 +160,37 @@ async function capturePhoto(
|
|
|
136
160
|
}
|
|
137
161
|
}
|
|
138
162
|
|
|
163
|
+
/** What the device actually is, for coordinate mapping (CAM-10). */
|
|
164
|
+
function surfaceInfoOf(
|
|
165
|
+
device: { readonly position?: string; readonly formats?: readonly DeviceFormat[] } | undefined,
|
|
166
|
+
fallback: CameraPosition,
|
|
167
|
+
): SurfaceInfo {
|
|
168
|
+
const format = device?.formats?.[0];
|
|
169
|
+
return {
|
|
170
|
+
imageWidth: format?.photoWidth ?? format?.videoWidth ?? 0,
|
|
171
|
+
imageHeight: format?.photoHeight ?? format?.videoHeight ?? 0,
|
|
172
|
+
orientation: 0,
|
|
173
|
+
cameraPosition:
|
|
174
|
+
device?.position === 'front' || device?.position === 'back' ? device.position : fallback,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** The slice of VisionCamera's `CameraDeviceFormat` the geometry needs. */
|
|
179
|
+
interface DeviceFormat {
|
|
180
|
+
readonly photoWidth?: number;
|
|
181
|
+
readonly photoHeight?: number;
|
|
182
|
+
readonly videoWidth?: number;
|
|
183
|
+
readonly videoHeight?: number;
|
|
184
|
+
}
|
|
185
|
+
|
|
139
186
|
export const VisionCameraFeed: React.FC<VisionCameraFeedProps> = ({
|
|
140
187
|
cameraPosition,
|
|
141
188
|
resizeMode,
|
|
142
189
|
torchEnabled,
|
|
190
|
+
isActive,
|
|
191
|
+
attachSurface,
|
|
192
|
+
onStarted,
|
|
193
|
+
onFailed,
|
|
143
194
|
}) => {
|
|
144
195
|
const device = useCameraDevice(cameraPosition);
|
|
145
196
|
const photoOutput = usePhotoOutput({ quality: DEFAULT_QUALITY });
|
|
@@ -151,7 +202,7 @@ export const VisionCameraFeed: React.FC<VisionCameraFeedProps> = ({
|
|
|
151
202
|
[photoOutput, cameraPosition],
|
|
152
203
|
);
|
|
153
204
|
|
|
154
|
-
useEffect(() =>
|
|
205
|
+
useEffect(() => attachSurface(surface), [attachSurface, surface]);
|
|
155
206
|
|
|
156
207
|
// VisionCamera applies `torchMode` on mount, before the camera is active, and
|
|
157
208
|
// leaves the native "Camera is not active" rejection unhandled — one red
|
|
@@ -172,7 +223,12 @@ export const VisionCameraFeed: React.FC<VisionCameraFeedProps> = ({
|
|
|
172
223
|
style={styles.feed}
|
|
173
224
|
device={device}
|
|
174
225
|
outputs={[photoOutput]}
|
|
175
|
-
|
|
226
|
+
// Mounted through `paused` and `starting`: unmounting the view tore the
|
|
227
|
+
// session down and rebuilding it cost the first-frame budget (CAM-09).
|
|
228
|
+
isActive={isActive}
|
|
229
|
+
// The session is active when frames arrive, not when it was asked for.
|
|
230
|
+
onStarted={() => onStarted(surfaceInfoOf(device, cameraPosition))}
|
|
231
|
+
onError={(error: unknown) => onFailed(nativeSessionCode(error))}
|
|
176
232
|
resizeMode={resizeMode}
|
|
177
233
|
// Left unset until the app first touches the torch, so the device's own default stands.
|
|
178
234
|
{...(torchMode === undefined ? {} : { torchMode })}
|
package/src/react/hooks.ts
CHANGED
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
type CameraControllerOptions,
|
|
16
16
|
} from '../core/controller';
|
|
17
17
|
|
|
18
|
-
export function useCameraController(options: CameraControllerOptions
|
|
18
|
+
export function useCameraController(options: CameraControllerOptions): CameraController {
|
|
19
19
|
// Options usually arrive as a fresh object literal each render; capturing
|
|
20
20
|
// them in a ref keeps the controller stable for the component's lifetime.
|
|
21
21
|
const optionsRef = useRef(options);
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
* the package can refuse to delete anything it did not create (FR-028).
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import { CameraError } from '../diagnostics/errors';
|
|
11
|
+
|
|
10
12
|
export interface ArtifactStore {
|
|
11
13
|
/** Absolute path of the package-owned temporary directory. */
|
|
12
14
|
temporaryDirectory(): string;
|
|
@@ -112,7 +114,10 @@ export function createArtifactStore(
|
|
|
112
114
|
return uri;
|
|
113
115
|
}
|
|
114
116
|
if (!fs.move) {
|
|
115
|
-
throw new
|
|
117
|
+
throw new CameraError(
|
|
118
|
+
'storageFailure',
|
|
119
|
+
'This FileSystemBridge has no move(), so it cannot adopt a capture',
|
|
120
|
+
);
|
|
116
121
|
}
|
|
117
122
|
await fs.ensureDirectory(root);
|
|
118
123
|
const name = path.slice(path.lastIndexOf('/') + 1) || `capture-${Date.now()}.jpg`;
|
|
@@ -132,7 +137,9 @@ export function createArtifactStore(
|
|
|
132
137
|
export function createCacheArtifactStore(options: StoreOptions = {}): ArtifactStore {
|
|
133
138
|
const fs = options.fs;
|
|
134
139
|
if (!fs) {
|
|
135
|
-
|
|
140
|
+
// A typed error, like every other failure this package reports.
|
|
141
|
+
throw new CameraError(
|
|
142
|
+
'storageFailure',
|
|
136
143
|
'createCacheArtifactStore requires a FileSystemBridge. Pass `fs`, or use ' +
|
|
137
144
|
'createTestCamera() in tests.',
|
|
138
145
|
);
|