@chipmobilesdk/rn-camera 0.1.1 → 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.
@@ -1,120 +1,126 @@
1
- /**
2
- * Camera preview surface (FR-009..FR-013, UX-001, UX-003).
3
- *
4
- * Renders the camera feed and nothing else: no shutter button, no permission
5
- * copy, no error text, and no theme dependency. Consuming apps draw their own
6
- * controls and overlays through `children`, positioned with the geometry
7
- * reported by `onGeometryChange`.
8
- */
9
-
10
- import React, { useCallback, useEffect, useMemo, useState } from 'react';
11
- import {
12
- View,
13
- type AccessibilityRole,
14
- type LayoutChangeEvent,
15
- type StyleProp,
16
- type ViewStyle,
17
- } from 'react-native';
18
-
19
- import type { PreviewGeometry, ResizeMode } from '../config/types';
20
- import type { CameraController } from '../core/controller';
21
- import type { VisionCameraFeedProps } from './VisionCameraFeed';
22
-
23
- export interface CameraPreviewProps {
24
- readonly controller: CameraController;
25
- readonly resizeMode?: ResizeMode;
26
- readonly style?: StyleProp<ViewStyle>;
27
- readonly onGeometryChange?: (geometry: PreviewGeometry) => void;
28
- /** Forwarded untouched; the package never overrides consumer a11y (UX-003). */
29
- readonly accessibilityLabel?: string;
30
- readonly accessibilityRole?: AccessibilityRole;
31
- /** Overlay layer rendered above the feed. */
32
- readonly children?: React.ReactNode;
33
- }
34
-
35
- /** Nominal capture dimensions until the engine reports the real format. */
36
- const DEFAULT_IMAGE_WIDTH = 1920;
37
- const DEFAULT_IMAGE_HEIGHT = 1080;
38
-
39
- export const CameraPreview: React.FC<CameraPreviewProps> = ({
40
- controller,
41
- resizeMode = 'cover',
42
- style,
43
- onGeometryChange,
44
- accessibilityLabel,
45
- accessibilityRole,
46
- children,
47
- }) => {
48
- const [layout, setLayout] = useState<{ width: number; height: number } | null>(null);
49
- const state = controller.state;
50
-
51
- // Tell the controller a surface exists so foregrounding can restart the
52
- // session, and clear it on unmount so the camera is released (FR-016).
53
- useEffect(() => {
54
- controller.setSurfaceMounted(true);
55
- return () => {
56
- controller.setSurfaceMounted(false);
57
- controller.stop().catch(() => undefined);
58
- };
59
- }, [controller]);
60
-
61
- const onLayout = useCallback((event: LayoutChangeEvent) => {
62
- const { width, height } = event.nativeEvent.layout;
63
- setLayout({ width, height });
64
- }, []);
65
-
66
- const geometry = useMemo<PreviewGeometry | null>(() => {
67
- if (!layout || layout.width === 0 || layout.height === 0) {
68
- return null;
69
- }
70
- return {
71
- imageWidth: DEFAULT_IMAGE_WIDTH,
72
- imageHeight: DEFAULT_IMAGE_HEIGHT,
73
- previewWidth: layout.width,
74
- previewHeight: layout.height,
75
- resizeMode,
76
- orientation: 0,
77
- isMirrored: state.cameraPosition === 'front',
78
- };
79
- }, [layout, resizeMode, state.cameraPosition]);
80
-
81
- useEffect(() => {
82
- if (geometry && onGeometryChange) {
83
- onGeometryChange(geometry);
84
- }
85
- }, [geometry, onGeometryChange]);
86
-
87
- const feedRequested = state.session === 'active' && state.permission === 'granted';
88
-
89
- // Lazy require so importing this package never pulls in the native module
90
- // for consumers that only use the pure utilities or the test adapter.
91
- const Feed = useMemo(() => {
92
- if (!feedRequested) {
93
- return null;
94
- }
95
- try {
96
- return require('./VisionCameraFeed')
97
- .VisionCameraFeed as React.ComponentType<VisionCameraFeedProps>;
98
- } catch {
99
- return null;
100
- }
101
- }, [feedRequested]);
102
-
103
- return (
104
- <View
105
- style={style}
106
- onLayout={onLayout}
107
- accessibilityLabel={accessibilityLabel}
108
- accessibilityRole={accessibilityRole}
109
- testID="rn-camera-preview">
110
- {Feed ? (
111
- <Feed
112
- cameraPosition={state.cameraPosition}
113
- resizeMode={resizeMode}
114
- torchEnabled={state.torchEnabled}
115
- />
116
- ) : null}
117
- {children}
118
- </View>
119
- );
120
- };
1
+ /**
2
+ * Camera preview surface (FR-009..FR-013, UX-001, UX-003).
3
+ *
4
+ * Renders the camera feed and nothing else: no shutter button, no permission
5
+ * copy, no error text, and no theme dependency. Consuming apps draw their own
6
+ * controls and overlays through `children`, positioned with the geometry
7
+ * reported by `onGeometryChange`.
8
+ */
9
+
10
+ import React, { useCallback, useEffect, useMemo, useState } from 'react';
11
+ import {
12
+ View,
13
+ type AccessibilityRole,
14
+ type LayoutChangeEvent,
15
+ type StyleProp,
16
+ type ViewStyle,
17
+ } from 'react-native';
18
+
19
+ import type { PreviewGeometry, ResizeMode } from '../config/types';
20
+ import type { CameraController } from '../core/controller';
21
+ import { useCameraState } from './hooks';
22
+ import type { VisionCameraFeedProps } from './VisionCameraFeed';
23
+
24
+ export interface CameraPreviewProps {
25
+ readonly controller: CameraController;
26
+ readonly resizeMode?: ResizeMode;
27
+ readonly style?: StyleProp<ViewStyle>;
28
+ readonly onGeometryChange?: (geometry: PreviewGeometry) => void;
29
+ /** Forwarded untouched; the package never overrides consumer a11y (UX-003). */
30
+ readonly accessibilityLabel?: string;
31
+ readonly accessibilityRole?: AccessibilityRole;
32
+ /** Overlay layer rendered above the feed. */
33
+ readonly children?: React.ReactNode;
34
+ }
35
+
36
+ /** Nominal capture dimensions until the engine reports the real format. */
37
+ const DEFAULT_IMAGE_WIDTH = 1920;
38
+ const DEFAULT_IMAGE_HEIGHT = 1080;
39
+
40
+ export const CameraPreview: React.FC<CameraPreviewProps> = ({
41
+ controller,
42
+ resizeMode = 'cover',
43
+ style,
44
+ onGeometryChange,
45
+ accessibilityLabel,
46
+ accessibilityRole,
47
+ children,
48
+ }) => {
49
+ const [layout, setLayout] = useState<{ width: number; height: number } | null>(null);
50
+ // Subscribed, not read once: a plain `controller.state` read only refreshed
51
+ // when the parent happened to re-render, so the feed could stay unmounted
52
+ // after the session became active (or stay mounted after it stopped).
53
+ const state = useCameraState(controller);
54
+
55
+ // Tell the controller a surface exists so foregrounding can restart the
56
+ // session, and clear it on unmount so the camera is released (FR-016). Both
57
+ // calls are no-ops on an idle or disposed controller.
58
+ useEffect(() => {
59
+ controller.setSurfaceMounted(true);
60
+ return () => {
61
+ controller.setSurfaceMounted(false);
62
+ controller.stop().catch(() => undefined);
63
+ };
64
+ }, [controller]);
65
+
66
+ const onLayout = useCallback((event: LayoutChangeEvent) => {
67
+ const { width, height } = event.nativeEvent.layout;
68
+ setLayout({ width, height });
69
+ }, []);
70
+
71
+ const geometry = useMemo<PreviewGeometry | null>(() => {
72
+ if (!layout || layout.width === 0 || layout.height === 0) {
73
+ return null;
74
+ }
75
+ return {
76
+ imageWidth: DEFAULT_IMAGE_WIDTH,
77
+ imageHeight: DEFAULT_IMAGE_HEIGHT,
78
+ previewWidth: layout.width,
79
+ previewHeight: layout.height,
80
+ resizeMode,
81
+ orientation: 0,
82
+ isMirrored: state.cameraPosition === 'front',
83
+ };
84
+ }, [layout, resizeMode, state.cameraPosition]);
85
+
86
+ useEffect(() => {
87
+ if (geometry && onGeometryChange) {
88
+ onGeometryChange(geometry);
89
+ }
90
+ }, [geometry, onGeometryChange]);
91
+
92
+ const feedRequested = state.session === 'active' && state.permission === 'granted';
93
+
94
+ // Lazy require so importing this package never pulls in the native module
95
+ // for consumers that only use the pure utilities or the test adapter.
96
+ const Feed = useMemo(() => {
97
+ if (!feedRequested) {
98
+ return null;
99
+ }
100
+ try {
101
+ return require('./VisionCameraFeed')
102
+ .VisionCameraFeed as React.ComponentType<VisionCameraFeedProps>;
103
+ } catch {
104
+ return null;
105
+ }
106
+ }, [feedRequested]);
107
+
108
+ return (
109
+ <View
110
+ style={style}
111
+ onLayout={onLayout}
112
+ accessibilityLabel={accessibilityLabel}
113
+ accessibilityRole={accessibilityRole}
114
+ testID="rn-camera-preview"
115
+ >
116
+ {Feed ? (
117
+ <Feed
118
+ cameraPosition={state.cameraPosition}
119
+ resizeMode={resizeMode}
120
+ torchEnabled={state.torchEnabled}
121
+ />
122
+ ) : null}
123
+ {children}
124
+ </View>
125
+ );
126
+ };
@@ -11,7 +11,7 @@
11
11
  * surface is mounted; it is published to the engine through `visionSurface`.
12
12
  */
13
13
 
14
- import React, { useEffect, useMemo } from 'react';
14
+ import React, { useEffect, useMemo, useState } from 'react';
15
15
  import { StyleSheet } from 'react-native';
16
16
  import {
17
17
  Camera,
@@ -28,8 +28,8 @@ import {
28
28
  type CaptureRequest,
29
29
  type ResizeMode,
30
30
  } from '../config/types';
31
- import { clampMaxDimension, clampQuality } from '../core/captureController';
32
- import { CameraError } from '../diagnostics/errors';
31
+ import { DEFAULT_QUALITY } from '../core/captureController';
32
+ import { CameraError, isCameraError } from '../diagnostics/errors';
33
33
  import { registerVisionSurface } from '../native/visionSurface';
34
34
 
35
35
  export interface VisionCameraFeedProps {
@@ -65,15 +65,22 @@ function toFilePath(uri: string): string {
65
65
  return uri.startsWith('file://') ? uri.slice('file://'.length) : uri;
66
66
  }
67
67
 
68
+ /** Native failures carry no code and may name a file path; never surface them raw. */
69
+ function asCameraError(error: unknown, code: 'captureFailed' | 'storageFailure'): CameraError {
70
+ return isCameraError(error) ? error : new CameraError(code);
71
+ }
72
+
68
73
  async function capturePhoto(
69
74
  photoOutput: CameraPhotoOutput,
70
75
  request: CaptureRequest,
71
76
  cameraPosition: CameraPosition,
72
77
  ): Promise<CapturedImage> {
73
- const photo = await photoOutput.capturePhoto(
74
- { flashMode: request.flashMode ?? 'off' },
75
- {},
76
- );
78
+ let photo: Awaited<ReturnType<CameraPhotoOutput['capturePhoto']>>;
79
+ try {
80
+ photo = await photoOutput.capturePhoto({ flashMode: request.flashMode ?? 'off' }, {});
81
+ } catch (error) {
82
+ throw asCameraError(error, 'captureFailed');
83
+ }
77
84
  try {
78
85
  // A destination makes the artifact app-owned, so it is written straight
79
86
  // there; otherwise it goes to a temporary file (FR-026a).
@@ -81,17 +88,21 @@ async function capturePhoto(
81
88
  if (request.destination) {
82
89
  try {
83
90
  await photo.saveToFileAsync(toFilePath(request.destination));
84
- } catch (error) {
91
+ } catch {
85
92
  throw new CameraError(
86
93
  'storageFailure',
87
- `Could not write the capture to the requested destination: ${
88
- error instanceof Error ? error.message : 'unknown error'
89
- }`,
94
+ 'Could not write the capture to the requested destination',
90
95
  );
91
96
  }
92
97
  path = request.destination;
93
98
  } else {
94
- path = await photo.saveToTemporaryFileAsync();
99
+ // VisionCamera's own temporary directory. The controller moves the file
100
+ // into the artifact store, when it has one, so cleanup can reach it.
101
+ try {
102
+ path = await photo.saveToTemporaryFileAsync();
103
+ } catch (error) {
104
+ throw asCameraError(error, 'storageFailure');
105
+ }
95
106
  }
96
107
 
97
108
  return {
@@ -104,8 +115,10 @@ async function capturePhoto(
104
115
  // Ownership is decided by the controller from the original request;
105
116
  // this value is overwritten there and is only a safe default.
106
117
  ownership: request.destination ? 'appOwned' : 'packageTemporary',
107
- effectiveQuality: clampQuality(request.quality),
108
- effectiveMaxDimension: clampMaxDimension(request.maxDimension),
118
+ // Quality is fixed on the photo output, not per capture, and nothing here
119
+ // resizes. Report what was actually applied rather than echo the hints.
120
+ effectiveQuality: DEFAULT_QUALITY,
121
+ effectiveMaxDimension: unavailable(),
109
122
  // The native layer reports neither back after a save, and guessing them
110
123
  // would be worse than saying so (FR-022).
111
124
  fileSize: unavailable(),
@@ -126,18 +139,27 @@ export const VisionCameraFeed: React.FC<VisionCameraFeedProps> = ({
126
139
  torchEnabled,
127
140
  }) => {
128
141
  const device = useCameraDevice(cameraPosition);
129
- const photoOutput = usePhotoOutput();
142
+ const photoOutput = usePhotoOutput({ quality: DEFAULT_QUALITY });
130
143
 
131
144
  const surface = useMemo(
132
145
  () => ({
133
- capture: (request: CaptureRequest) =>
134
- capturePhoto(photoOutput, request, cameraPosition),
146
+ capture: (request: CaptureRequest) => capturePhoto(photoOutput, request, cameraPosition),
135
147
  }),
136
148
  [photoOutput, cameraPosition],
137
149
  );
138
150
 
139
151
  useEffect(() => registerVisionSurface(surface), [surface]);
140
152
 
153
+ // VisionCamera applies `torchMode` on mount, before the camera is active, and
154
+ // leaves the native "Camera is not active" rejection unhandled — one red
155
+ // error per session start. Leave the prop unset until the torch is first
156
+ // turned on; after that 'off' must be sent explicitly to turn it back off.
157
+ const [torchTouched, setTorchTouched] = useState(false);
158
+ if (torchEnabled && !torchTouched) {
159
+ setTorchTouched(true);
160
+ }
161
+ const torchMode = torchEnabled ? 'on' : torchTouched ? 'off' : undefined;
162
+
141
163
  if (!device) {
142
164
  return null;
143
165
  }
@@ -149,7 +171,7 @@ export const VisionCameraFeed: React.FC<VisionCameraFeedProps> = ({
149
171
  outputs={[photoOutput]}
150
172
  isActive={true}
151
173
  resizeMode={resizeMode}
152
- torchMode={torchEnabled ? 'on' : 'off'}
174
+ torchMode={torchMode}
153
175
  />
154
176
  );
155
177
  };
@@ -1,44 +1,56 @@
1
- /**
2
- * React bindings for the camera controller.
3
- *
4
- * `useCameraController` owns the controller lifetime and disposes it on
5
- * unmount, which is what guarantees the camera is released when a screen goes
6
- * away (FR-016).
7
- */
8
-
9
- import { useEffect, useMemo, useRef, useState } from 'react';
10
-
11
- import type { CameraState } from '../config/types';
12
- import {
13
- createCameraController,
14
- type CameraController,
15
- type CameraControllerOptions,
16
- } from '../core/controller';
17
-
18
- export function useCameraController(
19
- options: CameraControllerOptions = {},
20
- ): CameraController {
21
- // Options usually arrive as a fresh object literal each render; capturing
22
- // them in a ref keeps the controller stable for the component's lifetime.
23
- const optionsRef = useRef(options);
24
- const controller = useMemo(() => createCameraController(optionsRef.current), []);
25
-
26
- useEffect(() => {
27
- return () => {
28
- controller.dispose().catch(() => undefined);
29
- };
30
- }, [controller]);
31
-
32
- return controller;
33
- }
34
-
35
- export function useCameraState(controller: CameraController): CameraState {
36
- const [state, setState] = useState<CameraState>(controller.state);
37
-
38
- useEffect(() => {
39
- setState(controller.state);
40
- return controller.subscribe(setState);
41
- }, [controller]);
42
-
43
- return state;
44
- }
1
+ /**
2
+ * React bindings for the camera controller.
3
+ *
4
+ * `useCameraController` owns the controller lifetime and disposes it on
5
+ * unmount, which is what guarantees the camera is released when a screen goes
6
+ * away (FR-016).
7
+ */
8
+
9
+ import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
10
+
11
+ import type { CameraState } from '../config/types';
12
+ import {
13
+ createCameraController,
14
+ type CameraController,
15
+ type CameraControllerOptions,
16
+ } from '../core/controller';
17
+
18
+ export function useCameraController(options: CameraControllerOptions = {}): CameraController {
19
+ // Options usually arrive as a fresh object literal each render; capturing
20
+ // them in a ref keeps the controller stable for the component's lifetime.
21
+ const optionsRef = useRef(options);
22
+ // Construction has no side effects (the controller subscribes on first use),
23
+ // so a controller React builds and discards here costs nothing.
24
+ const [controller, setController] = useState(() => createCameraController(optionsRef.current));
25
+ const disposedRef = useRef(new WeakSet<CameraController>());
26
+
27
+ useEffect(() => {
28
+ // StrictMode, and React's planned offscreen reuse, run the cleanup and then
29
+ // this effect again on the same component. Disposal is final, so the old
30
+ // controller cannot be revived; replace it. Before this, the screen kept a
31
+ // dead controller after the remount and the camera never started.
32
+ const disposed = disposedRef.current;
33
+ if (disposed.has(controller)) {
34
+ setController(createCameraController(optionsRef.current));
35
+ return undefined;
36
+ }
37
+ return () => {
38
+ disposed.add(controller);
39
+ controller.dispose().catch(() => undefined);
40
+ };
41
+ }, [controller]);
42
+
43
+ return controller;
44
+ }
45
+
46
+ export function useCameraState(controller: CameraController): CameraState {
47
+ // `subscribe` is a property of the controller object, so it is stable for the
48
+ // controller's lifetime, which is what useSyncExternalStore requires. The
49
+ // store read is tear-free, unlike the previous useState copy, which could
50
+ // miss a change made between render and effect.
51
+ return useSyncExternalStore(
52
+ controller.subscribe,
53
+ () => controller.state,
54
+ () => controller.state,
55
+ );
56
+ }