@chipmobilesdk/rn-camera 0.2.0 → 0.2.1
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 +107 -93
- package/README.md +364 -364
- package/package.json +44 -47
- package/skills/sdk-rn-camera/SKILL.md +74 -75
- package/src/config/types.ts +143 -149
- package/src/core/capabilities.ts +41 -44
- package/src/core/captureController.ts +120 -120
- package/src/core/controller.ts +516 -518
- package/src/core/sessionMachine.ts +104 -111
- package/src/diagnostics/errors.ts +92 -92
- package/src/diagnostics/logger.ts +26 -31
- package/src/geometry/coordinateMapping.ts +129 -129
- package/src/index.ts +78 -78
- package/src/react/CameraPreview.tsx +126 -125
- package/src/react/VisionCameraFeed.tsx +177 -178
- package/src/react/hooks.ts +56 -58
- package/src/storage/artifactStore.ts +141 -141
- package/src/testing/testCameraAdapter.ts +256 -256
|
@@ -1,111 +1,104 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Session lifecycle state machine (FR-014..FR-019).
|
|
3
|
-
*
|
|
4
|
-
* Pure and synchronous: it decides what a transition means, the controller
|
|
5
|
-
* performs the I/O. That split is what makes every lifecycle rule in
|
|
6
|
-
* `data-model.md` section 2 testable without a camera.
|
|
7
|
-
*
|
|
8
|
-
* Backgrounding STOPS rather than pauses. Both operating systems can reclaim
|
|
9
|
-
* the camera anyway, and holding it risks leaving the OS camera indicator lit
|
|
10
|
-
* after the app is no longer in front of the user (FR-015).
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import type { SessionStatus } from '../config/types';
|
|
14
|
-
import { CameraError, type CameraErrorCode } from '../diagnostics/errors';
|
|
15
|
-
|
|
16
|
-
export type SessionEvent =
|
|
17
|
-
| { readonly type: 'START' }
|
|
18
|
-
| { readonly type: 'STARTED' }
|
|
19
|
-
| { readonly type: 'PAUSE' }
|
|
20
|
-
| { readonly type: 'RESUME' }
|
|
21
|
-
| { readonly type: 'STOP' }
|
|
22
|
-
| { readonly type: 'STOPPED' }
|
|
23
|
-
| { readonly type: 'BACKGROUND' }
|
|
24
|
-
| { readonly type: 'FOREGROUND'; readonly surfaceMounted: boolean }
|
|
25
|
-
| { readonly type: 'FAIL'; readonly code: CameraErrorCode }
|
|
26
|
-
| { readonly type: 'START_TIMEOUT' };
|
|
27
|
-
|
|
28
|
-
export interface SessionSnapshot {
|
|
29
|
-
readonly status: SessionStatus;
|
|
30
|
-
readonly error: CameraError | null;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export const initialSession: SessionSnapshot = { status: 'idle', error: null };
|
|
34
|
-
|
|
35
|
-
/** Statuses from which a fresh start is allowed (FR-019). */
|
|
36
|
-
const STARTABLE: ReadonlySet<SessionStatus> = new Set<SessionStatus>([
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
return status
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
return
|
|
69
|
-
|
|
70
|
-
case '
|
|
71
|
-
return status === '
|
|
72
|
-
|
|
73
|
-
case '
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
return
|
|
91
|
-
|
|
92
|
-
case '
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
case 'FAIL':
|
|
106
|
-
return fail(event.code);
|
|
107
|
-
|
|
108
|
-
default:
|
|
109
|
-
return snapshot;
|
|
110
|
-
}
|
|
111
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Session lifecycle state machine (FR-014..FR-019).
|
|
3
|
+
*
|
|
4
|
+
* Pure and synchronous: it decides what a transition means, the controller
|
|
5
|
+
* performs the I/O. That split is what makes every lifecycle rule in
|
|
6
|
+
* `data-model.md` section 2 testable without a camera.
|
|
7
|
+
*
|
|
8
|
+
* Backgrounding STOPS rather than pauses. Both operating systems can reclaim
|
|
9
|
+
* the camera anyway, and holding it risks leaving the OS camera indicator lit
|
|
10
|
+
* after the app is no longer in front of the user (FR-015).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { SessionStatus } from '../config/types';
|
|
14
|
+
import { CameraError, type CameraErrorCode } from '../diagnostics/errors';
|
|
15
|
+
|
|
16
|
+
export type SessionEvent =
|
|
17
|
+
| { readonly type: 'START' }
|
|
18
|
+
| { readonly type: 'STARTED' }
|
|
19
|
+
| { readonly type: 'PAUSE' }
|
|
20
|
+
| { readonly type: 'RESUME' }
|
|
21
|
+
| { readonly type: 'STOP' }
|
|
22
|
+
| { readonly type: 'STOPPED' }
|
|
23
|
+
| { readonly type: 'BACKGROUND' }
|
|
24
|
+
| { readonly type: 'FOREGROUND'; readonly surfaceMounted: boolean }
|
|
25
|
+
| { readonly type: 'FAIL'; readonly code: CameraErrorCode }
|
|
26
|
+
| { readonly type: 'START_TIMEOUT' };
|
|
27
|
+
|
|
28
|
+
export interface SessionSnapshot {
|
|
29
|
+
readonly status: SessionStatus;
|
|
30
|
+
readonly error: CameraError | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const initialSession: SessionSnapshot = { status: 'idle', error: null };
|
|
34
|
+
|
|
35
|
+
/** Statuses from which a fresh start is allowed (FR-019). */
|
|
36
|
+
const STARTABLE: ReadonlySet<SessionStatus> = new Set<SessionStatus>(['idle', 'stopped', 'error']);
|
|
37
|
+
|
|
38
|
+
export function canStart(status: SessionStatus): boolean {
|
|
39
|
+
return STARTABLE.has(status);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Capture is only ever legal from a fully active session (FR-020). */
|
|
43
|
+
export function canCapture(status: SessionStatus): boolean {
|
|
44
|
+
return status === 'active';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function fail(code: CameraErrorCode): SessionSnapshot {
|
|
48
|
+
return { status: 'error', error: new CameraError(code) };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function sessionReducer(snapshot: SessionSnapshot, event: SessionEvent): SessionSnapshot {
|
|
52
|
+
const { status } = snapshot;
|
|
53
|
+
|
|
54
|
+
switch (event.type) {
|
|
55
|
+
case 'START':
|
|
56
|
+
if (!canStart(status)) {
|
|
57
|
+
// Refuse rather than silently stealing the camera from the surface
|
|
58
|
+
// that already holds it (FR-019).
|
|
59
|
+
return { status, error: new CameraError('sessionConflict') };
|
|
60
|
+
}
|
|
61
|
+
return { status: 'starting', error: null };
|
|
62
|
+
|
|
63
|
+
case 'STARTED':
|
|
64
|
+
return status === 'starting' ? { status: 'active', error: null } : snapshot;
|
|
65
|
+
|
|
66
|
+
case 'START_TIMEOUT':
|
|
67
|
+
// Never sit in `starting` forever when frames stop arriving.
|
|
68
|
+
return status === 'starting' ? fail('captureFailed') : snapshot;
|
|
69
|
+
|
|
70
|
+
case 'PAUSE':
|
|
71
|
+
return status === 'active' ? { status: 'paused', error: null } : snapshot;
|
|
72
|
+
|
|
73
|
+
case 'RESUME':
|
|
74
|
+
return status === 'paused' ? { status: 'starting', error: null } : snapshot;
|
|
75
|
+
|
|
76
|
+
case 'STOP':
|
|
77
|
+
if (status === 'stopped' || status === 'idle') {
|
|
78
|
+
return snapshot;
|
|
79
|
+
}
|
|
80
|
+
return { status: 'stopping', error: snapshot.error };
|
|
81
|
+
|
|
82
|
+
case 'STOPPED':
|
|
83
|
+
return { status: 'stopped', error: null };
|
|
84
|
+
|
|
85
|
+
case 'BACKGROUND':
|
|
86
|
+
// Release, do not hold (FR-015).
|
|
87
|
+
if (status === 'active' || status === 'paused' || status === 'starting') {
|
|
88
|
+
return { status: 'stopping', error: null };
|
|
89
|
+
}
|
|
90
|
+
return snapshot;
|
|
91
|
+
|
|
92
|
+
case 'FOREGROUND':
|
|
93
|
+
if (event.surfaceMounted && (status === 'stopped' || status === 'idle')) {
|
|
94
|
+
return { status: 'starting', error: null };
|
|
95
|
+
}
|
|
96
|
+
return snapshot;
|
|
97
|
+
|
|
98
|
+
case 'FAIL':
|
|
99
|
+
return fail(event.code);
|
|
100
|
+
|
|
101
|
+
default:
|
|
102
|
+
return snapshot;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -1,92 +1,92 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Typed error taxonomy (FR-044) and redaction helper (FR-038).
|
|
3
|
-
*
|
|
4
|
-
* The taxonomy exists so consuming apps can branch on `code` and `recoverable`
|
|
5
|
-
* instead of string-matching messages. Messages never carry file paths or image
|
|
6
|
-
* payloads — `redact` is applied before any value reaches a log sink.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
export type CameraErrorCode =
|
|
10
|
-
| 'permissionDenied'
|
|
11
|
-
| 'cameraUnavailable'
|
|
12
|
-
| 'cameraBlocked'
|
|
13
|
-
| 'cameraInUse'
|
|
14
|
-
| 'sessionConflict'
|
|
15
|
-
| 'notReady'
|
|
16
|
-
| 'captureInProgress'
|
|
17
|
-
| 'captureFailed'
|
|
18
|
-
| 'storageFailure'
|
|
19
|
-
| 'unsupportedCapability'
|
|
20
|
-
| 'interrupted';
|
|
21
|
-
|
|
22
|
-
/** Codes the app may retry; everything else ends the session. */
|
|
23
|
-
const RECOVERABLE: ReadonlySet<CameraErrorCode> = new Set<CameraErrorCode>([
|
|
24
|
-
'permissionDenied',
|
|
25
|
-
'cameraInUse',
|
|
26
|
-
'sessionConflict',
|
|
27
|
-
'notReady',
|
|
28
|
-
'captureInProgress',
|
|
29
|
-
'captureFailed',
|
|
30
|
-
'storageFailure',
|
|
31
|
-
'unsupportedCapability',
|
|
32
|
-
'interrupted',
|
|
33
|
-
]);
|
|
34
|
-
|
|
35
|
-
export class CameraError extends Error {
|
|
36
|
-
readonly code: CameraErrorCode;
|
|
37
|
-
readonly recoverable: boolean;
|
|
38
|
-
|
|
39
|
-
constructor(code: CameraErrorCode, message?: string) {
|
|
40
|
-
super(message ?? code);
|
|
41
|
-
this.name = 'CameraError';
|
|
42
|
-
this.code = code;
|
|
43
|
-
this.recoverable = RECOVERABLE.has(code);
|
|
44
|
-
Object.setPrototypeOf(this, CameraError.prototype);
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export function isCameraError(value: unknown): value is CameraError {
|
|
49
|
-
return value instanceof CameraError;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** Matches anything path-like: file URIs, POSIX paths, and Windows paths. */
|
|
53
|
-
const PATH_PATTERN =
|
|
54
|
-
/(?:[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^\s"']+)|(?:[a-zA-Z]:[\\/][^\s"']+)|(?:\/[^\s"']*\/[^\s"']*)/g;
|
|
55
|
-
/** Matches base64-ish runs long enough to be an encoded image payload. */
|
|
56
|
-
const PAYLOAD_PATTERN = /[A-Za-z0-9+/=]{64,}/g;
|
|
57
|
-
|
|
58
|
-
const REDACTED = '[redacted]';
|
|
59
|
-
const SENSITIVE_KEYS = new Set(['uri', 'path', 'destination', 'file', 'data', 'base64']);
|
|
60
|
-
|
|
61
|
-
function redactString(value: string): string {
|
|
62
|
-
return value.replace(PATH_PATTERN, REDACTED).replace(PAYLOAD_PATTERN, REDACTED);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Strips paths and payload-shaped strings from a value before logging (FR-038).
|
|
67
|
-
* Recurses through plain objects and arrays; known-sensitive keys are dropped
|
|
68
|
-
* wholesale rather than pattern-matched, since a bare filename has no separator
|
|
69
|
-
* for the path pattern to catch.
|
|
70
|
-
*/
|
|
71
|
-
export function redact(value: unknown): unknown {
|
|
72
|
-
if (typeof value === 'string') {
|
|
73
|
-
return redactString(value);
|
|
74
|
-
}
|
|
75
|
-
if (Array.isArray(value)) {
|
|
76
|
-
return value.map(redact);
|
|
77
|
-
}
|
|
78
|
-
if (value instanceof CameraError) {
|
|
79
|
-
return { code: value.code, recoverable: value.recoverable };
|
|
80
|
-
}
|
|
81
|
-
if (value instanceof Error) {
|
|
82
|
-
return { name: value.name, message: redactString(value.message) };
|
|
83
|
-
}
|
|
84
|
-
if (value && typeof value === 'object') {
|
|
85
|
-
const out: Record<string, unknown> = {};
|
|
86
|
-
for (const [key, item] of Object.entries(value)) {
|
|
87
|
-
out[key] = SENSITIVE_KEYS.has(key) ? REDACTED : redact(item);
|
|
88
|
-
}
|
|
89
|
-
return out;
|
|
90
|
-
}
|
|
91
|
-
return value;
|
|
92
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Typed error taxonomy (FR-044) and redaction helper (FR-038).
|
|
3
|
+
*
|
|
4
|
+
* The taxonomy exists so consuming apps can branch on `code` and `recoverable`
|
|
5
|
+
* instead of string-matching messages. Messages never carry file paths or image
|
|
6
|
+
* payloads — `redact` is applied before any value reaches a log sink.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type CameraErrorCode =
|
|
10
|
+
| 'permissionDenied'
|
|
11
|
+
| 'cameraUnavailable'
|
|
12
|
+
| 'cameraBlocked'
|
|
13
|
+
| 'cameraInUse'
|
|
14
|
+
| 'sessionConflict'
|
|
15
|
+
| 'notReady'
|
|
16
|
+
| 'captureInProgress'
|
|
17
|
+
| 'captureFailed'
|
|
18
|
+
| 'storageFailure'
|
|
19
|
+
| 'unsupportedCapability'
|
|
20
|
+
| 'interrupted';
|
|
21
|
+
|
|
22
|
+
/** Codes the app may retry; everything else ends the session. */
|
|
23
|
+
const RECOVERABLE: ReadonlySet<CameraErrorCode> = new Set<CameraErrorCode>([
|
|
24
|
+
'permissionDenied',
|
|
25
|
+
'cameraInUse',
|
|
26
|
+
'sessionConflict',
|
|
27
|
+
'notReady',
|
|
28
|
+
'captureInProgress',
|
|
29
|
+
'captureFailed',
|
|
30
|
+
'storageFailure',
|
|
31
|
+
'unsupportedCapability',
|
|
32
|
+
'interrupted',
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
export class CameraError extends Error {
|
|
36
|
+
readonly code: CameraErrorCode;
|
|
37
|
+
readonly recoverable: boolean;
|
|
38
|
+
|
|
39
|
+
constructor(code: CameraErrorCode, message?: string) {
|
|
40
|
+
super(message ?? code);
|
|
41
|
+
this.name = 'CameraError';
|
|
42
|
+
this.code = code;
|
|
43
|
+
this.recoverable = RECOVERABLE.has(code);
|
|
44
|
+
Object.setPrototypeOf(this, CameraError.prototype);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function isCameraError(value: unknown): value is CameraError {
|
|
49
|
+
return value instanceof CameraError;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Matches anything path-like: file URIs, POSIX paths, and Windows paths. */
|
|
53
|
+
const PATH_PATTERN =
|
|
54
|
+
/(?:[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^\s"']+)|(?:[a-zA-Z]:[\\/][^\s"']+)|(?:\/[^\s"']*\/[^\s"']*)/g;
|
|
55
|
+
/** Matches base64-ish runs long enough to be an encoded image payload. */
|
|
56
|
+
const PAYLOAD_PATTERN = /[A-Za-z0-9+/=]{64,}/g;
|
|
57
|
+
|
|
58
|
+
const REDACTED = '[redacted]';
|
|
59
|
+
const SENSITIVE_KEYS = new Set(['uri', 'path', 'destination', 'file', 'data', 'base64']);
|
|
60
|
+
|
|
61
|
+
function redactString(value: string): string {
|
|
62
|
+
return value.replace(PATH_PATTERN, REDACTED).replace(PAYLOAD_PATTERN, REDACTED);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Strips paths and payload-shaped strings from a value before logging (FR-038).
|
|
67
|
+
* Recurses through plain objects and arrays; known-sensitive keys are dropped
|
|
68
|
+
* wholesale rather than pattern-matched, since a bare filename has no separator
|
|
69
|
+
* for the path pattern to catch.
|
|
70
|
+
*/
|
|
71
|
+
export function redact(value: unknown): unknown {
|
|
72
|
+
if (typeof value === 'string') {
|
|
73
|
+
return redactString(value);
|
|
74
|
+
}
|
|
75
|
+
if (Array.isArray(value)) {
|
|
76
|
+
return value.map(redact);
|
|
77
|
+
}
|
|
78
|
+
if (value instanceof CameraError) {
|
|
79
|
+
return { code: value.code, recoverable: value.recoverable };
|
|
80
|
+
}
|
|
81
|
+
if (value instanceof Error) {
|
|
82
|
+
return { name: value.name, message: redactString(value.message) };
|
|
83
|
+
}
|
|
84
|
+
if (value && typeof value === 'object') {
|
|
85
|
+
const out: Record<string, unknown> = {};
|
|
86
|
+
for (const [key, item] of Object.entries(value)) {
|
|
87
|
+
out[key] = SENSITIVE_KEYS.has(key) ? REDACTED : redact(item);
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
return value;
|
|
92
|
+
}
|
|
@@ -1,31 +1,26 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Optional consumer-supplied logger (research R4).
|
|
3
|
-
*
|
|
4
|
-
* The default is no logging at all, so FR-038 holds whether or not a consumer
|
|
5
|
-
* opts in. Event bodies carry codes and states only — never file paths, never
|
|
6
|
-
* image data — so an opted-in logger still cannot leak an image path.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
export type CameraLogEventType =
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
export
|
|
23
|
-
log(
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
export const silentLogger: CameraLogger = {
|
|
28
|
-
log() {
|
|
29
|
-
// Intentionally empty: logging is opt-in.
|
|
30
|
-
},
|
|
31
|
-
};
|
|
1
|
+
/**
|
|
2
|
+
* Optional consumer-supplied logger (research R4).
|
|
3
|
+
*
|
|
4
|
+
* The default is no logging at all, so FR-038 holds whether or not a consumer
|
|
5
|
+
* opts in. Event bodies carry codes and states only — never file paths, never
|
|
6
|
+
* image data — so an opted-in logger still cannot leak an image path.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type CameraLogEventType = 'session' | 'permission' | 'capture' | 'capability' | 'cleanup';
|
|
10
|
+
|
|
11
|
+
export interface CameraLogEvent {
|
|
12
|
+
readonly type: CameraLogEventType;
|
|
13
|
+
readonly code: string;
|
|
14
|
+
readonly recoverable?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface CameraLogger {
|
|
18
|
+
log(event: CameraLogEvent): void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** No-op logger used when the consumer supplies none. */
|
|
22
|
+
export const silentLogger: CameraLogger = {
|
|
23
|
+
log() {
|
|
24
|
+
// Intentionally empty: logging is opt-in.
|
|
25
|
+
},
|
|
26
|
+
};
|