@chipmobilesdk/rn-camera 0.2.0 → 0.3.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.
@@ -1,129 +1,129 @@
1
- /**
2
- * Bidirectional coordinate mapping between preview space and captured-image
3
- * space (FR-036, SC-012).
4
- *
5
- * Pure functions with no native involvement (research R5). Everything needed is
6
- * already known in JavaScript, so keeping this pure is what makes the 2-pixel
7
- * round-trip tolerance verifiable in CI rather than only on a device.
8
- *
9
- * Composition order, preview -> image: undo mirror, undo letterbox/crop offset,
10
- * undo scale, undo orientation. Image -> preview reverses it exactly.
11
- */
12
-
13
- import type { Point, PreviewGeometry, Rect } from '../config/types';
14
-
15
- /** Orientation rotates the image for display, swapping dimensions at 90/270. */
16
- function isQuarterTurn(orientation: number): boolean {
17
- const normalized = ((orientation % 360) + 360) % 360;
18
- return normalized === 90 || normalized === 270;
19
- }
20
-
21
- function normalizeOrientation(orientation: number): 0 | 90 | 180 | 270 {
22
- const normalized = ((Math.round(orientation / 90) * 90) % 360 + 360) % 360;
23
- return normalized as 0 | 90 | 180 | 270;
24
- }
25
-
26
- interface Projection {
27
- readonly scale: number;
28
- readonly offsetX: number;
29
- readonly offsetY: number;
30
- /** Image dimensions as displayed, after orientation. */
31
- readonly displayWidth: number;
32
- readonly displayHeight: number;
33
- }
34
-
35
- function projection(geometry: PreviewGeometry): Projection {
36
- const swap = isQuarterTurn(geometry.orientation);
37
- const displayWidth = swap ? geometry.imageHeight : geometry.imageWidth;
38
- const displayHeight = swap ? geometry.imageWidth : geometry.imageHeight;
39
-
40
- const scaleX = geometry.previewWidth / displayWidth;
41
- const scaleY = geometry.previewHeight / displayHeight;
42
- // `cover` fills and crops the overflow; `contain` fits and letterboxes.
43
- const scale =
44
- geometry.resizeMode === 'cover' ? Math.max(scaleX, scaleY) : Math.min(scaleX, scaleY);
45
-
46
- return {
47
- scale,
48
- // Negative under `cover` (cropped away), positive under `contain`
49
- // (letterbox bands). The same arithmetic covers both.
50
- offsetX: (geometry.previewWidth - displayWidth * scale) / 2,
51
- offsetY: (geometry.previewHeight - displayHeight * scale) / 2,
52
- displayWidth,
53
- displayHeight,
54
- };
55
- }
56
-
57
- /** Display-space point -> original image point, undoing the rotation. */
58
- function unrotate(point: Point, geometry: PreviewGeometry): Point {
59
- switch (normalizeOrientation(geometry.orientation)) {
60
- case 90:
61
- return { x: point.y, y: geometry.imageHeight - point.x };
62
- case 180:
63
- return { x: geometry.imageWidth - point.x, y: geometry.imageHeight - point.y };
64
- case 270:
65
- return { x: geometry.imageWidth - point.y, y: point.x };
66
- default:
67
- return point;
68
- }
69
- }
70
-
71
- /** Original image point -> display-space point, applying the rotation. */
72
- function rotate(point: Point, geometry: PreviewGeometry): Point {
73
- switch (normalizeOrientation(geometry.orientation)) {
74
- case 90:
75
- return { x: geometry.imageHeight - point.y, y: point.x };
76
- case 180:
77
- return { x: geometry.imageWidth - point.x, y: geometry.imageHeight - point.y };
78
- case 270:
79
- return { x: point.y, y: geometry.imageWidth - point.x };
80
- default:
81
- return point;
82
- }
83
- }
84
-
85
- /**
86
- * Maps a preview-space point into captured-image space. Points outside the
87
- * visible region stay representable (they fall outside the image bounds) rather
88
- * than being clamped to an edge, so callers can detect an out-of-frame overlay.
89
- */
90
- export function mapPointToImage(point: Point, geometry: PreviewGeometry): Point {
91
- const { scale, offsetX, offsetY } = projection(geometry);
92
- const x = geometry.isMirrored ? geometry.previewWidth - point.x : point.x;
93
- return unrotate({ x: (x - offsetX) / scale, y: (point.y - offsetY) / scale }, geometry);
94
- }
95
-
96
- /** Maps a captured-image point into preview space. Exact inverse of the above. */
97
- export function mapPointToPreview(point: Point, geometry: PreviewGeometry): Point {
98
- const { scale, offsetX, offsetY } = projection(geometry);
99
- const rotated = rotate(point, geometry);
100
- const x = rotated.x * scale + offsetX;
101
- return {
102
- x: geometry.isMirrored ? geometry.previewWidth - x : x,
103
- y: rotated.y * scale + offsetY,
104
- };
105
- }
106
-
107
- /** Axis-aligned bounding box of the mapped corners — exact for quarter turns. */
108
- function boundsOf(a: Point, b: Point): Rect {
109
- return {
110
- x: Math.min(a.x, b.x),
111
- y: Math.min(a.y, b.y),
112
- width: Math.abs(b.x - a.x),
113
- height: Math.abs(b.y - a.y),
114
- };
115
- }
116
-
117
- export function mapRectToImage(rect: Rect, geometry: PreviewGeometry): Rect {
118
- return boundsOf(
119
- mapPointToImage({ x: rect.x, y: rect.y }, geometry),
120
- mapPointToImage({ x: rect.x + rect.width, y: rect.y + rect.height }, geometry),
121
- );
122
- }
123
-
124
- export function mapRectToPreview(rect: Rect, geometry: PreviewGeometry): Rect {
125
- return boundsOf(
126
- mapPointToPreview({ x: rect.x, y: rect.y }, geometry),
127
- mapPointToPreview({ x: rect.x + rect.width, y: rect.y + rect.height }, geometry),
128
- );
129
- }
1
+ /**
2
+ * Bidirectional coordinate mapping between preview space and captured-image
3
+ * space (FR-036, SC-012).
4
+ *
5
+ * Pure functions with no native involvement (research R5). Everything needed is
6
+ * already known in JavaScript, so keeping this pure is what makes the 2-pixel
7
+ * round-trip tolerance verifiable in CI rather than only on a device.
8
+ *
9
+ * Composition order, preview -> image: undo mirror, undo letterbox/crop offset,
10
+ * undo scale, undo orientation. Image -> preview reverses it exactly.
11
+ */
12
+
13
+ import type { Point, PreviewGeometry, Rect } from '../config/types';
14
+
15
+ /** Orientation rotates the image for display, swapping dimensions at 90/270. */
16
+ function isQuarterTurn(orientation: number): boolean {
17
+ const normalized = ((orientation % 360) + 360) % 360;
18
+ return normalized === 90 || normalized === 270;
19
+ }
20
+
21
+ function normalizeOrientation(orientation: number): 0 | 90 | 180 | 270 {
22
+ const normalized = (((Math.round(orientation / 90) * 90) % 360) + 360) % 360;
23
+ return normalized as 0 | 90 | 180 | 270;
24
+ }
25
+
26
+ interface Projection {
27
+ readonly scale: number;
28
+ readonly offsetX: number;
29
+ readonly offsetY: number;
30
+ /** Image dimensions as displayed, after orientation. */
31
+ readonly displayWidth: number;
32
+ readonly displayHeight: number;
33
+ }
34
+
35
+ function projection(geometry: PreviewGeometry): Projection {
36
+ const swap = isQuarterTurn(geometry.orientation);
37
+ const displayWidth = swap ? geometry.imageHeight : geometry.imageWidth;
38
+ const displayHeight = swap ? geometry.imageWidth : geometry.imageHeight;
39
+
40
+ const scaleX = geometry.previewWidth / displayWidth;
41
+ const scaleY = geometry.previewHeight / displayHeight;
42
+ // `cover` fills and crops the overflow; `contain` fits and letterboxes.
43
+ const scale =
44
+ geometry.resizeMode === 'cover' ? Math.max(scaleX, scaleY) : Math.min(scaleX, scaleY);
45
+
46
+ return {
47
+ scale,
48
+ // Negative under `cover` (cropped away), positive under `contain`
49
+ // (letterbox bands). The same arithmetic covers both.
50
+ offsetX: (geometry.previewWidth - displayWidth * scale) / 2,
51
+ offsetY: (geometry.previewHeight - displayHeight * scale) / 2,
52
+ displayWidth,
53
+ displayHeight,
54
+ };
55
+ }
56
+
57
+ /** Display-space point -> original image point, undoing the rotation. */
58
+ function unrotate(point: Point, geometry: PreviewGeometry): Point {
59
+ switch (normalizeOrientation(geometry.orientation)) {
60
+ case 90:
61
+ return { x: point.y, y: geometry.imageHeight - point.x };
62
+ case 180:
63
+ return { x: geometry.imageWidth - point.x, y: geometry.imageHeight - point.y };
64
+ case 270:
65
+ return { x: geometry.imageWidth - point.y, y: point.x };
66
+ default:
67
+ return point;
68
+ }
69
+ }
70
+
71
+ /** Original image point -> display-space point, applying the rotation. */
72
+ function rotate(point: Point, geometry: PreviewGeometry): Point {
73
+ switch (normalizeOrientation(geometry.orientation)) {
74
+ case 90:
75
+ return { x: geometry.imageHeight - point.y, y: point.x };
76
+ case 180:
77
+ return { x: geometry.imageWidth - point.x, y: geometry.imageHeight - point.y };
78
+ case 270:
79
+ return { x: point.y, y: geometry.imageWidth - point.x };
80
+ default:
81
+ return point;
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Maps a preview-space point into captured-image space. Points outside the
87
+ * visible region stay representable (they fall outside the image bounds) rather
88
+ * than being clamped to an edge, so callers can detect an out-of-frame overlay.
89
+ */
90
+ export function mapPointToImage(point: Point, geometry: PreviewGeometry): Point {
91
+ const { scale, offsetX, offsetY } = projection(geometry);
92
+ const x = geometry.isMirrored ? geometry.previewWidth - point.x : point.x;
93
+ return unrotate({ x: (x - offsetX) / scale, y: (point.y - offsetY) / scale }, geometry);
94
+ }
95
+
96
+ /** Maps a captured-image point into preview space. Exact inverse of the above. */
97
+ export function mapPointToPreview(point: Point, geometry: PreviewGeometry): Point {
98
+ const { scale, offsetX, offsetY } = projection(geometry);
99
+ const rotated = rotate(point, geometry);
100
+ const x = rotated.x * scale + offsetX;
101
+ return {
102
+ x: geometry.isMirrored ? geometry.previewWidth - x : x,
103
+ y: rotated.y * scale + offsetY,
104
+ };
105
+ }
106
+
107
+ /** Axis-aligned bounding box of the mapped corners — exact for quarter turns. */
108
+ function boundsOf(a: Point, b: Point): Rect {
109
+ return {
110
+ x: Math.min(a.x, b.x),
111
+ y: Math.min(a.y, b.y),
112
+ width: Math.abs(b.x - a.x),
113
+ height: Math.abs(b.y - a.y),
114
+ };
115
+ }
116
+
117
+ export function mapRectToImage(rect: Rect, geometry: PreviewGeometry): Rect {
118
+ return boundsOf(
119
+ mapPointToImage({ x: rect.x, y: rect.y }, geometry),
120
+ mapPointToImage({ x: rect.x + rect.width, y: rect.y + rect.height }, geometry),
121
+ );
122
+ }
123
+
124
+ export function mapRectToPreview(rect: Rect, geometry: PreviewGeometry): Rect {
125
+ return boundsOf(
126
+ mapPointToPreview({ x: rect.x, y: rect.y }, geometry),
127
+ mapPointToPreview({ x: rect.x + rect.width, y: rect.y + rect.height }, geometry),
128
+ );
129
+ }
package/src/index.ts CHANGED
@@ -1,78 +1,78 @@
1
- /**
2
- * @chipmobilesdk/rn-camera — reusable camera infrastructure for
3
- * ChipMobileSdk-family React Native apps.
4
- *
5
- * This is the single public entrypoint (LIB-003). Modules under `core/`,
6
- * `native/`, `permissions/`, `storage/`, `geometry/`, `diagnostics/`, and
7
- * `testing/` are private; import them only through this file.
8
- *
9
- * The package contains no business logic, sends nothing over the network, and
10
- * writes nothing to the device gallery. Phase one runs no on-device inference —
11
- * see the reserved frame-processing extension point in the README.
12
- */
13
-
14
- // Public types
15
- export type {
16
- ArtifactOwnership,
17
- CameraErrorLike,
18
- CameraPermissionState,
19
- CameraPosition,
20
- CameraState,
21
- CapabilityProfile,
22
- CapturedImage,
23
- CaptureRequest,
24
- FlashMode,
25
- Known,
26
- Maybe,
27
- Point,
28
- PreviewGeometry,
29
- Rect,
30
- ResizeMode,
31
- SessionStatus,
32
- StartOptions,
33
- Unavailable,
34
- } from './config/types';
35
- export { known, unavailable } from './config/types';
36
-
37
- // Controller
38
- export { createCameraController, DEFAULT_TEMPORARY_TTL_MS } from './core/controller';
39
- export type {
40
- AppStatus,
41
- CameraController,
42
- CameraControllerOptions,
43
- } from './core/controller';
44
-
45
- // Ports + default adapters
46
- export type { CameraEngine, EngineOptions } from './native/cameraEngine';
47
- export { createVisionCameraEngine } from './native/cameraEngine';
48
- export type { PermissionPort } from './permissions/permissionPort';
49
- export { createVisionCameraPermissions } from './permissions/permissionPort';
50
- export type { ArtifactStore, FileSystemBridge, StoreOptions } from './storage/artifactStore';
51
- export { createArtifactStore, createCacheArtifactStore } from './storage/artifactStore';
52
-
53
- // React surface
54
- export { CameraPreview } from './react/CameraPreview';
55
- export type { CameraPreviewProps } from './react/CameraPreview';
56
- export { useCameraController, useCameraState } from './react/hooks';
57
-
58
- // Coordinate mapping (pure)
59
- export {
60
- mapPointToImage,
61
- mapPointToPreview,
62
- mapRectToImage,
63
- mapRectToPreview,
64
- } from './geometry/coordinateMapping';
65
-
66
- // Diagnostics
67
- export { CameraError, isCameraError, redact } from './diagnostics/errors';
68
- export type { CameraErrorCode } from './diagnostics/errors';
69
- export type { CameraLogEvent, CameraLogEventType, CameraLogger } from './diagnostics/logger';
70
-
71
- // Test adapter (FR-041) — exported from the single entrypoint so consumer tests
72
- // exercise the same published ports the real adapters implement.
73
- export { createTestCamera, createMemoryFileSystem, DEFAULT_TEST_CAPABILITIES } from './testing/testCameraAdapter';
74
- export type {
75
- MemoryFileSystem,
76
- TestCameraConfig,
77
- TestCameraHandles,
78
- } from './testing/testCameraAdapter';
1
+ /**
2
+ * @chipmobilesdk/rn-camera — reusable camera infrastructure for
3
+ * ChipMobileSdk-family React Native apps.
4
+ *
5
+ * This is the single public entrypoint (LIB-003). Modules under `core/`,
6
+ * `native/`, `permissions/`, `storage/`, `geometry/`, `diagnostics/`, and
7
+ * `testing/` are private; import them only through this file.
8
+ *
9
+ * The package contains no business logic, sends nothing over the network, and
10
+ * writes nothing to the device gallery. Phase one runs no on-device inference —
11
+ * see the reserved frame-processing extension point in the README.
12
+ */
13
+
14
+ // Public types
15
+ export type {
16
+ ArtifactOwnership,
17
+ CameraErrorLike,
18
+ CameraPermissionState,
19
+ CameraPosition,
20
+ CameraState,
21
+ CapabilityProfile,
22
+ CapturedImage,
23
+ CaptureRequest,
24
+ FlashMode,
25
+ Known,
26
+ Maybe,
27
+ Point,
28
+ PreviewGeometry,
29
+ Rect,
30
+ ResizeMode,
31
+ SessionStatus,
32
+ StartOptions,
33
+ Unavailable,
34
+ } from './config/types';
35
+ export { known, unavailable } from './config/types';
36
+
37
+ // Controller
38
+ export { createCameraController, DEFAULT_TEMPORARY_TTL_MS } from './core/controller';
39
+ export type { AppStatus, CameraController, CameraControllerOptions } from './core/controller';
40
+
41
+ // Ports + default adapters
42
+ export type { CameraEngine, EngineOptions } from './native/cameraEngine';
43
+ export { createVisionCameraEngine } from './native/cameraEngine';
44
+ export type { PermissionPort } from './permissions/permissionPort';
45
+ export { createVisionCameraPermissions } from './permissions/permissionPort';
46
+ export type { ArtifactStore, FileSystemBridge, StoreOptions } from './storage/artifactStore';
47
+ export { createArtifactStore, createCacheArtifactStore } from './storage/artifactStore';
48
+
49
+ // React surface
50
+ export { CameraPreview } from './react/CameraPreview';
51
+ export type { CameraPreviewProps } from './react/CameraPreview';
52
+ export { useCameraController, useCameraState } from './react/hooks';
53
+
54
+ // Coordinate mapping (pure)
55
+ export {
56
+ mapPointToImage,
57
+ mapPointToPreview,
58
+ mapRectToImage,
59
+ mapRectToPreview,
60
+ } from './geometry/coordinateMapping';
61
+
62
+ // Diagnostics
63
+ export { CameraError, isCameraError, redact } from './diagnostics/errors';
64
+ export type { CameraErrorCode } from './diagnostics/errors';
65
+ export type { CameraLogEvent, CameraLogEventType, CameraLogger } from './diagnostics/logger';
66
+
67
+ // Test adapter (FR-041) — exported from the single entrypoint so consumer tests
68
+ // exercise the same published ports the real adapters implement.
69
+ export {
70
+ createTestCamera,
71
+ createMemoryFileSystem,
72
+ DEFAULT_TEST_CAPABILITIES,
73
+ } from './testing/testCameraAdapter';
74
+ export type {
75
+ MemoryFileSystem,
76
+ TestCameraConfig,
77
+ TestCameraHandles,
78
+ } from './testing/testCameraAdapter';
@@ -14,6 +14,7 @@
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,
@@ -49,8 +50,12 @@ interface DiscoveredDevice {
49
50
  * owns the rendered surface and the photo output that capture runs through.
50
51
  */
51
52
  export function createVisionCameraEngine(_options: EngineOptions = {}): CameraEngine {
52
- // Lazy require: only loaded when the default adapter is actually used.
53
- const { VisionCamera } = require('react-native-vision-camera');
53
+ // Loaded when the default adapter is created; a missing peer is `cameraUnavailable`.
54
+ const { VisionCamera } = loadVisionCamera<{
55
+ VisionCamera: {
56
+ createDeviceFactory(): Promise<{ readonly cameraDevices: DiscoveredDevice[] }>;
57
+ };
58
+ }>();
54
59
 
55
60
  // The factory is created once and reused: it keeps its own device list up to
56
61
  // date, so re-creating it per call would only add latency.
@@ -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
+ }
@@ -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
- // Lazy require: only loaded when the default adapter is actually used.
50
- const { VisionCamera } = require('react-native-vision-camera');
51
- const { AppState } = require('react-native');
52
- const listeners = new Set<(state: CameraPermissionState) => void>();
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 appStateSubscription: { remove(): void } | null = null;
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
- for (const listener of listeners) {
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
- if (!appStateSubscription) {
100
- appStateSubscription = AppState.addEventListener('change', (next: string) => {
101
- if (next === 'active') {
102
- emit(read());
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
- listeners.delete(listener);
108
- if (listeners.size === 0 && appStateSubscription) {
109
- appStateSubscription.remove();
110
- appStateSubscription = null;
111
+ remove();
112
+ if (listeners.size === 0 && stopWatchingLifecycle) {
113
+ stopWatchingLifecycle();
114
+ stopWatchingLifecycle = null;
111
115
  }
112
116
  };
113
117
  },