@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
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* payloads — `redact` is applied before any value reaches a log sink.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { COMMON_PATTERNS, PATH_PATTERN, SdkError, redactPatterns } from '@chipmobilesdk/rn-core';
|
|
10
|
+
|
|
9
11
|
export type CameraErrorCode =
|
|
10
12
|
| 'permissionDenied'
|
|
11
13
|
| 'cameraUnavailable'
|
|
@@ -32,16 +34,16 @@ const RECOVERABLE: ReadonlySet<CameraErrorCode> = new Set<CameraErrorCode>([
|
|
|
32
34
|
'interrupted',
|
|
33
35
|
]);
|
|
34
36
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
readonly recoverable: boolean;
|
|
38
|
-
|
|
37
|
+
/** The package's error; an `SdkError` (`@chipmobilesdk/rn-core`) with `pkg: 'rn-camera'`. */
|
|
38
|
+
export class CameraError extends SdkError<CameraErrorCode> {
|
|
39
39
|
constructor(code: CameraErrorCode, message?: string) {
|
|
40
|
-
super(message
|
|
40
|
+
super('rn-camera', code, { message, retryable: RECOVERABLE.has(code) });
|
|
41
41
|
this.name = 'CameraError';
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** @deprecated Use `retryable`, the name every SDK package shares. */
|
|
45
|
+
get recoverable(): boolean {
|
|
46
|
+
return this.retryable;
|
|
45
47
|
}
|
|
46
48
|
}
|
|
47
49
|
|
|
@@ -49,17 +51,17 @@ export function isCameraError(value: unknown): value is CameraError {
|
|
|
49
51
|
return value instanceof CameraError;
|
|
50
52
|
}
|
|
51
53
|
|
|
52
|
-
/**
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const
|
|
54
|
+
/**
|
|
55
|
+
* rn-core's shared patterns (URLs, including file URIs; JWTs; e-mail addresses;
|
|
56
|
+
* UUIDs; base64-ish payload runs) plus POSIX and Windows paths.
|
|
57
|
+
*/
|
|
58
|
+
const PATTERNS = [...COMMON_PATTERNS, PATH_PATTERN];
|
|
57
59
|
|
|
58
60
|
const REDACTED = '[redacted]';
|
|
59
61
|
const SENSITIVE_KEYS = new Set(['uri', 'path', 'destination', 'file', 'data', 'base64']);
|
|
60
62
|
|
|
61
63
|
function redactString(value: string): string {
|
|
62
|
-
return value
|
|
64
|
+
return redactPatterns(value, PATTERNS);
|
|
63
65
|
}
|
|
64
66
|
|
|
65
67
|
/**
|
|
@@ -76,7 +78,7 @@ export function redact(value: unknown): unknown {
|
|
|
76
78
|
return value.map(redact);
|
|
77
79
|
}
|
|
78
80
|
if (value instanceof CameraError) {
|
|
79
|
-
return { code: value.code, recoverable: value.
|
|
81
|
+
return { code: value.code, recoverable: value.retryable };
|
|
80
82
|
}
|
|
81
83
|
if (value instanceof Error) {
|
|
82
84
|
return { name: value.name, message: redactString(value.message) };
|
|
@@ -6,18 +6,39 @@
|
|
|
6
6
|
* image data — so an opted-in logger still cannot leak an image path.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { createDiagnosticChannel } from '@chipmobilesdk/rn-core';
|
|
10
|
+
|
|
9
11
|
export type CameraLogEventType = 'session' | 'permission' | 'capture' | 'capability' | 'cleanup';
|
|
10
12
|
|
|
11
13
|
export interface CameraLogEvent {
|
|
12
14
|
readonly type: CameraLogEventType;
|
|
13
15
|
readonly code: string;
|
|
14
|
-
readonly recoverable?: boolean;
|
|
16
|
+
readonly recoverable?: boolean | undefined;
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
export interface CameraLogger {
|
|
18
20
|
log(event: CameraLogEvent): void;
|
|
19
21
|
}
|
|
20
22
|
|
|
23
|
+
const channel = createDiagnosticChannel('rn-camera');
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Every event also reaches the app's SDK sinks (`installSdkSink`, CORE-08), as
|
|
27
|
+
* `<type>.<code>`: the same code-and-state body, so nothing new can leak.
|
|
28
|
+
*/
|
|
29
|
+
export function withSdkSinks(logger: CameraLogger): CameraLogger {
|
|
30
|
+
return {
|
|
31
|
+
log(event) {
|
|
32
|
+
channel.emit(
|
|
33
|
+
`${event.type}.${event.code}`,
|
|
34
|
+
event.code === 'listener-threw' ? 'error' : event.recoverable === false ? 'warn' : 'info',
|
|
35
|
+
event.recoverable === undefined ? undefined : { retryable: event.recoverable },
|
|
36
|
+
);
|
|
37
|
+
logger.log(event);
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
21
42
|
/** No-op logger used when the consumer supplies none. */
|
|
22
43
|
export const silentLogger: CameraLogger = {
|
|
23
44
|
log() {
|
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';
|
|
@@ -14,14 +14,16 @@
|
|
|
14
14
|
* mounted preview surface (see `visionSurface`).
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
+
import { loadVisionCamera } from './visionCameraPeer';
|
|
17
18
|
import type {
|
|
18
19
|
CapabilityProfile,
|
|
19
20
|
CapturedImage,
|
|
20
21
|
CaptureRequest,
|
|
21
22
|
StartOptions,
|
|
22
23
|
} from '../config/types';
|
|
24
|
+
import type { CameraErrorCode } from '../diagnostics/errors';
|
|
23
25
|
import { CameraError } from '../diagnostics/errors';
|
|
24
|
-
import {
|
|
26
|
+
import { createSurfaceRegistry, type VisionSurface } from './visionSurface';
|
|
25
27
|
|
|
26
28
|
export interface CameraEngine {
|
|
27
29
|
start(options: StartOptions): Promise<void>;
|
|
@@ -29,11 +31,24 @@ export interface CameraEngine {
|
|
|
29
31
|
capture(request: CaptureRequest): Promise<CapturedImage>;
|
|
30
32
|
setTorch(enabled: boolean): Promise<void>;
|
|
31
33
|
getCapabilities(): Promise<CapabilityProfile>;
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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;
|
|
37
52
|
}
|
|
38
53
|
|
|
39
54
|
/** The subset of VisionCamera's `CameraDevice` this adapter reads. */
|
|
@@ -48,9 +63,15 @@ interface DiscoveredDevice {
|
|
|
48
63
|
* component, so this adapter owns capability discovery while `CameraPreview`
|
|
49
64
|
* owns the rendered surface and the photo output that capture runs through.
|
|
50
65
|
*/
|
|
51
|
-
export function createVisionCameraEngine(
|
|
52
|
-
//
|
|
53
|
-
const
|
|
66
|
+
export function createVisionCameraEngine(): CameraEngine {
|
|
67
|
+
// One registry per engine, so two controllers never share a surface.
|
|
68
|
+
const surfaces = createSurfaceRegistry();
|
|
69
|
+
// Loaded when the default adapter is created; a missing peer is `cameraUnavailable`.
|
|
70
|
+
const { VisionCamera } = loadVisionCamera<{
|
|
71
|
+
VisionCamera: {
|
|
72
|
+
createDeviceFactory(): Promise<{ readonly cameraDevices: DiscoveredDevice[] }>;
|
|
73
|
+
};
|
|
74
|
+
}>();
|
|
54
75
|
|
|
55
76
|
// The factory is created once and reused: it keeps its own device list up to
|
|
56
77
|
// date, so re-creating it per call would only add latency.
|
|
@@ -62,6 +83,10 @@ export function createVisionCameraEngine(_options: EngineOptions = {}): CameraEn
|
|
|
62
83
|
}
|
|
63
84
|
|
|
64
85
|
return {
|
|
86
|
+
awaitsSurfaceStart: true,
|
|
87
|
+
|
|
88
|
+
attachSurface: surface => surfaces.attach(surface),
|
|
89
|
+
|
|
65
90
|
async start() {
|
|
66
91
|
// The preview component owns session activation; the engine only needs
|
|
67
92
|
// to confirm a device exists before the surface mounts.
|
|
@@ -76,7 +101,7 @@ export function createVisionCameraEngine(_options: EngineOptions = {}): CameraEn
|
|
|
76
101
|
},
|
|
77
102
|
|
|
78
103
|
async capture(request: CaptureRequest) {
|
|
79
|
-
const surface =
|
|
104
|
+
const surface = surfaces.current();
|
|
80
105
|
if (!surface) {
|
|
81
106
|
throw new CameraError(
|
|
82
107
|
'notReady',
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* react-native-vision-camera, loaded on first use (rn-core's lazy peer). A missing
|
|
3
|
+
* or unlinked module is a `cameraUnavailable` CameraError, the code the controller
|
|
4
|
+
* already reports when the camera cannot be reached.
|
|
5
|
+
*/
|
|
6
|
+
import { lazyPeer } from '@chipmobilesdk/rn-core';
|
|
7
|
+
|
|
8
|
+
import { CameraError } from '../diagnostics/errors';
|
|
9
|
+
|
|
10
|
+
// The engine and the permission port each describe the slice they use.
|
|
11
|
+
type VisionCameraModule = { VisionCamera: Record<string, (...args: never[]) => unknown> };
|
|
12
|
+
|
|
13
|
+
const visionCamera = lazyPeer('react-native-vision-camera', () => {
|
|
14
|
+
// Inside `try`, so Metro bundles an app that leaves this optional peer out.
|
|
15
|
+
try {
|
|
16
|
+
return require('react-native-vision-camera') as VisionCameraModule;
|
|
17
|
+
} catch (cause) {
|
|
18
|
+
throw cause;
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export function loadVisionCamera<T>(): T {
|
|
23
|
+
return visionCamera.require(
|
|
24
|
+
peer =>
|
|
25
|
+
new CameraError(
|
|
26
|
+
'cameraUnavailable',
|
|
27
|
+
`${peer} is not installed or its native module is not linked`,
|
|
28
|
+
),
|
|
29
|
+
) as unknown as T;
|
|
30
|
+
}
|
|
@@ -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
|
}
|
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
* statics since v5).
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
import { loadVisionCamera } from '../native/visionCameraPeer';
|
|
16
|
+
import { appLifecycle, createListenerSet } from '@chipmobilesdk/rn-core';
|
|
17
|
+
|
|
15
18
|
import type { CameraPermissionState } from '../config/types';
|
|
16
19
|
|
|
17
20
|
export interface PermissionPort {
|
|
@@ -46,12 +49,16 @@ function mapStatus(raw: string): CameraPermissionState {
|
|
|
46
49
|
}
|
|
47
50
|
|
|
48
51
|
export function createVisionCameraPermissions(): PermissionPort {
|
|
49
|
-
//
|
|
50
|
-
const { VisionCamera } =
|
|
51
|
-
|
|
52
|
-
|
|
52
|
+
// Loaded when the default adapter is created; a missing peer is `cameraUnavailable`.
|
|
53
|
+
const { VisionCamera } = loadVisionCamera<{
|
|
54
|
+
VisionCamera: {
|
|
55
|
+
readonly cameraPermissionStatus: string;
|
|
56
|
+
requestCameraPermission(): Promise<unknown>;
|
|
57
|
+
};
|
|
58
|
+
}>();
|
|
59
|
+
const listeners = createListenerSet<[CameraPermissionState]>();
|
|
53
60
|
let last: CameraPermissionState | null = null;
|
|
54
|
-
let
|
|
61
|
+
let stopWatchingLifecycle: (() => void) | null = null;
|
|
55
62
|
|
|
56
63
|
function read(): CameraPermissionState {
|
|
57
64
|
try {
|
|
@@ -66,9 +73,7 @@ export function createVisionCameraPermissions(): PermissionPort {
|
|
|
66
73
|
function emit(state: CameraPermissionState): CameraPermissionState {
|
|
67
74
|
if (state !== last) {
|
|
68
75
|
last = state;
|
|
69
|
-
|
|
70
|
-
listener(state);
|
|
71
|
-
}
|
|
76
|
+
listeners.notify(state);
|
|
72
77
|
}
|
|
73
78
|
return state;
|
|
74
79
|
}
|
|
@@ -92,22 +97,21 @@ export function createVisionCameraPermissions(): PermissionPort {
|
|
|
92
97
|
},
|
|
93
98
|
|
|
94
99
|
subscribe(listener) {
|
|
95
|
-
listeners.add(listener);
|
|
100
|
+
const remove = listeners.add(listener);
|
|
96
101
|
// Grants and revocations made in system Settings send the app through
|
|
97
102
|
// background/foreground, so re-reading on foreground is what surfaces
|
|
98
103
|
// them (FR-007). Attached only while somebody is listening.
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
}
|
|
104
|
+
// rn-core's shared lifecycle port, so this adds no native subscription of its own.
|
|
105
|
+
stopWatchingLifecycle ??= appLifecycle().subscribe(state => {
|
|
106
|
+
if (state === 'active') {
|
|
107
|
+
emit(read());
|
|
108
|
+
}
|
|
109
|
+
});
|
|
106
110
|
return () => {
|
|
107
|
-
|
|
108
|
-
if (listeners.size === 0 &&
|
|
109
|
-
|
|
110
|
-
|
|
111
|
+
remove();
|
|
112
|
+
if (listeners.size === 0 && stopWatchingLifecycle) {
|
|
113
|
+
stopWatchingLifecycle();
|
|
114
|
+
stopWatchingLifecycle = null;
|
|
111
115
|
}
|
|
112
116
|
};
|
|
113
117
|
},
|
|
@@ -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
|
|
|
@@ -33,7 +35,18 @@ export interface CameraPreviewProps {
|
|
|
33
35
|
readonly children?: React.ReactNode;
|
|
34
36
|
}
|
|
35
37
|
|
|
36
|
-
/**
|
|
38
|
+
/** The feed module failed to load; distinct from `null`, which means not requested. */
|
|
39
|
+
const FEED_UNAVAILABLE = 'unavailable' as const;
|
|
40
|
+
|
|
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
|
+
*/
|
|
37
50
|
const DEFAULT_IMAGE_WIDTH = 1920;
|
|
38
51
|
const DEFAULT_IMAGE_HEIGHT = 1080;
|
|
39
52
|
|
|
@@ -63,6 +76,19 @@ export const CameraPreview: React.FC<CameraPreviewProps> = ({
|
|
|
63
76
|
};
|
|
64
77
|
}, [controller]);
|
|
65
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
|
+
|
|
66
92
|
const onLayout = useCallback((event: LayoutChangeEvent) => {
|
|
67
93
|
const { width, height } = event.nativeEvent.layout;
|
|
68
94
|
setLayout({ width, height });
|
|
@@ -72,16 +98,17 @@ export const CameraPreview: React.FC<CameraPreviewProps> = ({
|
|
|
72
98
|
if (!layout || layout.width === 0 || layout.height === 0) {
|
|
73
99
|
return null;
|
|
74
100
|
}
|
|
101
|
+
const surface = state.surface;
|
|
75
102
|
return {
|
|
76
|
-
imageWidth: DEFAULT_IMAGE_WIDTH,
|
|
77
|
-
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,
|
|
78
105
|
previewWidth: layout.width,
|
|
79
106
|
previewHeight: layout.height,
|
|
80
107
|
resizeMode,
|
|
81
|
-
orientation: 0,
|
|
82
|
-
isMirrored: state.cameraPosition === 'front',
|
|
108
|
+
orientation: surface?.orientation ?? 0,
|
|
109
|
+
isMirrored: (surface?.cameraPosition ?? state.cameraPosition) === 'front',
|
|
83
110
|
};
|
|
84
|
-
}, [layout, resizeMode, state.cameraPosition]);
|
|
111
|
+
}, [layout, resizeMode, state.cameraPosition, state.surface]);
|
|
85
112
|
|
|
86
113
|
useEffect(() => {
|
|
87
114
|
if (geometry && onGeometryChange) {
|
|
@@ -89,7 +116,12 @@ export const CameraPreview: React.FC<CameraPreviewProps> = ({
|
|
|
89
116
|
}
|
|
90
117
|
}, [geometry, onGeometryChange]);
|
|
91
118
|
|
|
92
|
-
|
|
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');
|
|
93
125
|
|
|
94
126
|
// Lazy require so importing this package never pulls in the native module
|
|
95
127
|
// for consumers that only use the pure utilities or the test adapter.
|
|
@@ -101,10 +133,18 @@ export const CameraPreview: React.FC<CameraPreviewProps> = ({
|
|
|
101
133
|
return require('./VisionCameraFeed')
|
|
102
134
|
.VisionCameraFeed as React.ComponentType<VisionCameraFeedProps>;
|
|
103
135
|
} catch {
|
|
104
|
-
|
|
136
|
+
// Reported, not swallowed (CAM-21): the session fails with
|
|
137
|
+
// `cameraUnavailable` instead of showing a black preview.
|
|
138
|
+
return FEED_UNAVAILABLE;
|
|
105
139
|
}
|
|
106
140
|
}, [feedRequested]);
|
|
107
141
|
|
|
142
|
+
useEffect(() => {
|
|
143
|
+
if (Feed === FEED_UNAVAILABLE) {
|
|
144
|
+
controller.reportSurfaceUnavailable();
|
|
145
|
+
}
|
|
146
|
+
}, [Feed, controller]);
|
|
147
|
+
|
|
108
148
|
return (
|
|
109
149
|
<View
|
|
110
150
|
style={style}
|
|
@@ -113,11 +153,15 @@ export const CameraPreview: React.FC<CameraPreviewProps> = ({
|
|
|
113
153
|
accessibilityRole={accessibilityRole}
|
|
114
154
|
testID="rn-camera-preview"
|
|
115
155
|
>
|
|
116
|
-
{Feed ? (
|
|
156
|
+
{Feed && Feed !== FEED_UNAVAILABLE ? (
|
|
117
157
|
<Feed
|
|
118
158
|
cameraPosition={state.cameraPosition}
|
|
119
159
|
resizeMode={resizeMode}
|
|
120
160
|
torchEnabled={state.torchEnabled}
|
|
161
|
+
isActive={state.session !== 'paused'}
|
|
162
|
+
attachSurface={attachSurface}
|
|
163
|
+
onStarted={onSurfaceStarted}
|
|
164
|
+
onFailed={onSurfaceFailed}
|
|
121
165
|
/>
|
|
122
166
|
) : null}
|
|
123
167
|
{children}
|
|
@@ -26,16 +26,25 @@ import {
|
|
|
26
26
|
type CameraPosition,
|
|
27
27
|
type CapturedImage,
|
|
28
28
|
type CaptureRequest,
|
|
29
|
+
type Maybe,
|
|
29
30
|
type ResizeMode,
|
|
30
31
|
} from '../config/types';
|
|
31
32
|
import { DEFAULT_QUALITY } from '../core/captureController';
|
|
32
|
-
import { CameraError, isCameraError } from '../diagnostics/errors';
|
|
33
|
-
import {
|
|
33
|
+
import { CameraError, isCameraError, type CameraErrorCode } from '../diagnostics/errors';
|
|
34
|
+
import type { SurfaceInfo, VisionSurface } from '../native/visionSurface';
|
|
34
35
|
|
|
35
36
|
export interface VisionCameraFeedProps {
|
|
36
37
|
readonly cameraPosition: CameraPosition;
|
|
37
38
|
readonly resizeMode: ResizeMode;
|
|
38
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;
|
|
39
48
|
}
|
|
40
49
|
|
|
41
50
|
const styles = StyleSheet.create({
|
|
@@ -57,6 +66,11 @@ const ORIENTATION_DEGREES: Record<string, number> = {
|
|
|
57
66
|
left: 270,
|
|
58
67
|
};
|
|
59
68
|
|
|
69
|
+
function orientationOf(orientation: string): Maybe<number> {
|
|
70
|
+
const degrees = ORIENTATION_DEGREES[orientation];
|
|
71
|
+
return degrees === undefined ? unavailable() : known(degrees);
|
|
72
|
+
}
|
|
73
|
+
|
|
60
74
|
function toFileUri(path: string): string {
|
|
61
75
|
return path.startsWith('file://') ? path : `file://${path}`;
|
|
62
76
|
}
|
|
@@ -65,6 +79,20 @@ function toFilePath(uri: string): string {
|
|
|
65
79
|
return uri.startsWith('file://') ? uri.slice('file://'.length) : uri;
|
|
66
80
|
}
|
|
67
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
|
+
|
|
68
96
|
/** Native failures carry no code and may name a file path; never surface them raw. */
|
|
69
97
|
function asCameraError(error: unknown, code: 'captureFailed' | 'storageFailure'): CameraError {
|
|
70
98
|
return isCameraError(error) ? error : new CameraError(code);
|
|
@@ -73,6 +101,7 @@ function asCameraError(error: unknown, code: 'captureFailed' | 'storageFailure')
|
|
|
73
101
|
async function capturePhoto(
|
|
74
102
|
photoOutput: CameraPhotoOutput,
|
|
75
103
|
request: CaptureRequest,
|
|
104
|
+
/** The device actually mounted; the request cannot ask for another one. */
|
|
76
105
|
cameraPosition: CameraPosition,
|
|
77
106
|
): Promise<CapturedImage> {
|
|
78
107
|
let photo: Awaited<ReturnType<CameraPhotoOutput['capturePhoto']>>;
|
|
@@ -111,7 +140,8 @@ async function capturePhoto(
|
|
|
111
140
|
width: photo.width,
|
|
112
141
|
height: photo.height,
|
|
113
142
|
timestamp: Date.now(),
|
|
114
|
-
|
|
143
|
+
// The camera that took it, not the one the request named (CAM-06).
|
|
144
|
+
cameraPosition,
|
|
115
145
|
// Ownership is decided by the controller from the original request;
|
|
116
146
|
// this value is overwritten there and is only a safe default.
|
|
117
147
|
ownership: request.destination ? 'appOwned' : 'packageTemporary',
|
|
@@ -122,10 +152,7 @@ async function capturePhoto(
|
|
|
122
152
|
// The native layer reports neither back after a save, and guessing them
|
|
123
153
|
// would be worse than saying so (FR-022).
|
|
124
154
|
fileSize: unavailable(),
|
|
125
|
-
orientation:
|
|
126
|
-
photo.orientation in ORIENTATION_DEGREES
|
|
127
|
-
? known(ORIENTATION_DEGREES[photo.orientation])
|
|
128
|
-
: unavailable(),
|
|
155
|
+
orientation: orientationOf(photo.orientation),
|
|
129
156
|
};
|
|
130
157
|
} finally {
|
|
131
158
|
// Large native buffers must not wait for the JS GC.
|
|
@@ -133,10 +160,37 @@ async function capturePhoto(
|
|
|
133
160
|
}
|
|
134
161
|
}
|
|
135
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
|
+
|
|
136
186
|
export const VisionCameraFeed: React.FC<VisionCameraFeedProps> = ({
|
|
137
187
|
cameraPosition,
|
|
138
188
|
resizeMode,
|
|
139
189
|
torchEnabled,
|
|
190
|
+
isActive,
|
|
191
|
+
attachSurface,
|
|
192
|
+
onStarted,
|
|
193
|
+
onFailed,
|
|
140
194
|
}) => {
|
|
141
195
|
const device = useCameraDevice(cameraPosition);
|
|
142
196
|
const photoOutput = usePhotoOutput({ quality: DEFAULT_QUALITY });
|
|
@@ -148,7 +202,7 @@ export const VisionCameraFeed: React.FC<VisionCameraFeedProps> = ({
|
|
|
148
202
|
[photoOutput, cameraPosition],
|
|
149
203
|
);
|
|
150
204
|
|
|
151
|
-
useEffect(() =>
|
|
205
|
+
useEffect(() => attachSurface(surface), [attachSurface, surface]);
|
|
152
206
|
|
|
153
207
|
// VisionCamera applies `torchMode` on mount, before the camera is active, and
|
|
154
208
|
// leaves the native "Camera is not active" rejection unhandled — one red
|
|
@@ -169,9 +223,15 @@ export const VisionCameraFeed: React.FC<VisionCameraFeedProps> = ({
|
|
|
169
223
|
style={styles.feed}
|
|
170
224
|
device={device}
|
|
171
225
|
outputs={[photoOutput]}
|
|
172
|
-
|
|
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))}
|
|
173
232
|
resizeMode={resizeMode}
|
|
174
|
-
|
|
233
|
+
// Left unset until the app first touches the torch, so the device's own default stands.
|
|
234
|
+
{...(torchMode === undefined ? {} : { torchMode })}
|
|
175
235
|
/>
|
|
176
236
|
);
|
|
177
237
|
};
|