@chipmobilesdk/rn-camera 0.1.0 → 0.2.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,155 +1,178 @@
1
- /**
2
- * The live VisionCamera surface rendered inside `CameraPreview`.
3
- *
4
- * Split into its own module so `CameraPreview` can require it lazily: consumers
5
- * that only use the pure utilities or the test adapter never load the native
6
- * module, while the hooks this component needs (`useCameraDevice`,
7
- * `usePhotoOutput`) can still be called normally.
8
- *
9
- * It also owns capture. In VisionCamera v5 the photo output belongs to the
10
- * session the `<Camera>` view configures, so the output only exists while this
11
- * surface is mounted; it is published to the engine through `visionSurface`.
12
- */
13
-
14
- import React, { useEffect, useMemo } from 'react';
15
- import { StyleSheet } from 'react-native';
16
- import {
17
- Camera,
18
- useCameraDevice,
19
- usePhotoOutput,
20
- type CameraPhotoOutput,
21
- } from 'react-native-vision-camera';
22
-
23
- import {
24
- known,
25
- unavailable,
26
- type CameraPosition,
27
- type CapturedImage,
28
- type CaptureRequest,
29
- type ResizeMode,
30
- } from '../config/types';
31
- import { clampMaxDimension, clampQuality } from '../core/captureController';
32
- import { CameraError } from '../diagnostics/errors';
33
- import { registerVisionSurface } from '../native/visionSurface';
34
-
35
- export interface VisionCameraFeedProps {
36
- readonly cameraPosition: CameraPosition;
37
- readonly resizeMode: ResizeMode;
38
- readonly torchEnabled: boolean;
39
- }
40
-
41
- const styles = StyleSheet.create({
42
- feed: { flex: 1 },
43
- });
44
-
45
- const MIME_TYPES: Record<string, string> = {
46
- jpeg: 'image/jpeg',
47
- heic: 'image/heic',
48
- dng: 'image/x-adobe-dng',
49
- tiff: 'image/tiff',
50
- };
51
-
52
- /** VisionCamera reports orientation as a compass value; the contract wants degrees. */
53
- const ORIENTATION_DEGREES: Record<string, number> = {
54
- up: 0,
55
- right: 90,
56
- down: 180,
57
- left: 270,
58
- };
59
-
60
- function toFileUri(path: string): string {
61
- return path.startsWith('file://') ? path : `file://${path}`;
62
- }
63
-
64
- function toFilePath(uri: string): string {
65
- return uri.startsWith('file://') ? uri.slice('file://'.length) : uri;
66
- }
67
-
68
- async function capturePhoto(
69
- photoOutput: CameraPhotoOutput,
70
- request: CaptureRequest,
71
- cameraPosition: CameraPosition,
72
- ): Promise<CapturedImage> {
73
- const photo = await photoOutput.capturePhoto(
74
- { flashMode: request.flashMode ?? 'off' },
75
- {},
76
- );
77
- try {
78
- // A destination makes the artifact app-owned, so it is written straight
79
- // there; otherwise it goes to a temporary file (FR-026a).
80
- let path: string;
81
- if (request.destination) {
82
- try {
83
- await photo.saveToFileAsync(toFilePath(request.destination));
84
- } catch (error) {
85
- throw new CameraError(
86
- 'storageFailure',
87
- `Could not write the capture to the requested destination: ${
88
- error instanceof Error ? error.message : 'unknown error'
89
- }`,
90
- );
91
- }
92
- path = request.destination;
93
- } else {
94
- path = await photo.saveToTemporaryFileAsync();
95
- }
96
-
97
- return {
98
- uri: toFileUri(path),
99
- mimeType: MIME_TYPES[photo.containerFormat] ?? 'application/octet-stream',
100
- width: photo.width,
101
- height: photo.height,
102
- timestamp: Date.now(),
103
- cameraPosition: request.cameraPosition ?? cameraPosition,
104
- // Ownership is decided by the controller from the original request;
105
- // this value is overwritten there and is only a safe default.
106
- ownership: request.destination ? 'appOwned' : 'packageTemporary',
107
- effectiveQuality: clampQuality(request.quality),
108
- effectiveMaxDimension: clampMaxDimension(request.maxDimension),
109
- // The native layer reports neither back after a save, and guessing them
110
- // would be worse than saying so (FR-022).
111
- fileSize: unavailable(),
112
- orientation:
113
- photo.orientation in ORIENTATION_DEGREES
114
- ? known(ORIENTATION_DEGREES[photo.orientation])
115
- : unavailable(),
116
- };
117
- } finally {
118
- // Large native buffers must not wait for the JS GC.
119
- photo.dispose();
120
- }
121
- }
122
-
123
- export const VisionCameraFeed: React.FC<VisionCameraFeedProps> = ({
124
- cameraPosition,
125
- resizeMode,
126
- torchEnabled,
127
- }) => {
128
- const device = useCameraDevice(cameraPosition);
129
- const photoOutput = usePhotoOutput();
130
-
131
- const surface = useMemo(
132
- () => ({
133
- capture: (request: CaptureRequest) =>
134
- capturePhoto(photoOutput, request, cameraPosition),
135
- }),
136
- [photoOutput, cameraPosition],
137
- );
138
-
139
- useEffect(() => registerVisionSurface(surface), [surface]);
140
-
141
- if (!device) {
142
- return null;
143
- }
144
-
145
- return (
146
- <Camera
147
- style={styles.feed}
148
- device={device}
149
- outputs={[photoOutput]}
150
- isActive={true}
151
- resizeMode={resizeMode}
152
- torchMode={torchEnabled ? 'on' : 'off'}
153
- />
154
- );
155
- };
1
+ /**
2
+ * The live VisionCamera surface rendered inside `CameraPreview`.
3
+ *
4
+ * Split into its own module so `CameraPreview` can require it lazily: consumers
5
+ * that only use the pure utilities or the test adapter never load the native
6
+ * module, while the hooks this component needs (`useCameraDevice`,
7
+ * `usePhotoOutput`) can still be called normally.
8
+ *
9
+ * It also owns capture. In VisionCamera v5 the photo output belongs to the
10
+ * session the `<Camera>` view configures, so the output only exists while this
11
+ * surface is mounted; it is published to the engine through `visionSurface`.
12
+ */
13
+
14
+ import React, { useEffect, useMemo, useState } from 'react';
15
+ import { StyleSheet } from 'react-native';
16
+ import {
17
+ Camera,
18
+ useCameraDevice,
19
+ usePhotoOutput,
20
+ type CameraPhotoOutput,
21
+ } from 'react-native-vision-camera';
22
+
23
+ import {
24
+ known,
25
+ unavailable,
26
+ type CameraPosition,
27
+ type CapturedImage,
28
+ type CaptureRequest,
29
+ type ResizeMode,
30
+ } from '../config/types';
31
+ import { DEFAULT_QUALITY } from '../core/captureController';
32
+ import { CameraError, isCameraError } from '../diagnostics/errors';
33
+ import { registerVisionSurface } from '../native/visionSurface';
34
+
35
+ export interface VisionCameraFeedProps {
36
+ readonly cameraPosition: CameraPosition;
37
+ readonly resizeMode: ResizeMode;
38
+ readonly torchEnabled: boolean;
39
+ }
40
+
41
+ const styles = StyleSheet.create({
42
+ feed: { flex: 1 },
43
+ });
44
+
45
+ const MIME_TYPES: Record<string, string> = {
46
+ jpeg: 'image/jpeg',
47
+ heic: 'image/heic',
48
+ dng: 'image/x-adobe-dng',
49
+ tiff: 'image/tiff',
50
+ };
51
+
52
+ /** VisionCamera reports orientation as a compass value; the contract wants degrees. */
53
+ const ORIENTATION_DEGREES: Record<string, number> = {
54
+ up: 0,
55
+ right: 90,
56
+ down: 180,
57
+ left: 270,
58
+ };
59
+
60
+ function toFileUri(path: string): string {
61
+ return path.startsWith('file://') ? path : `file://${path}`;
62
+ }
63
+
64
+ function toFilePath(uri: string): string {
65
+ return uri.startsWith('file://') ? uri.slice('file://'.length) : uri;
66
+ }
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
+
73
+ async function capturePhoto(
74
+ photoOutput: CameraPhotoOutput,
75
+ request: CaptureRequest,
76
+ cameraPosition: CameraPosition,
77
+ ): Promise<CapturedImage> {
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
+ }
84
+ try {
85
+ // A destination makes the artifact app-owned, so it is written straight
86
+ // there; otherwise it goes to a temporary file (FR-026a).
87
+ let path: string;
88
+ if (request.destination) {
89
+ try {
90
+ await photo.saveToFileAsync(toFilePath(request.destination));
91
+ } catch {
92
+ throw new CameraError(
93
+ 'storageFailure',
94
+ 'Could not write the capture to the requested destination',
95
+ );
96
+ }
97
+ path = request.destination;
98
+ } else {
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
+ }
106
+ }
107
+
108
+ return {
109
+ uri: toFileUri(path),
110
+ mimeType: MIME_TYPES[photo.containerFormat] ?? 'application/octet-stream',
111
+ width: photo.width,
112
+ height: photo.height,
113
+ timestamp: Date.now(),
114
+ cameraPosition: request.cameraPosition ?? cameraPosition,
115
+ // Ownership is decided by the controller from the original request;
116
+ // this value is overwritten there and is only a safe default.
117
+ ownership: request.destination ? 'appOwned' : 'packageTemporary',
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(),
122
+ // The native layer reports neither back after a save, and guessing them
123
+ // would be worse than saying so (FR-022).
124
+ fileSize: unavailable(),
125
+ orientation:
126
+ photo.orientation in ORIENTATION_DEGREES
127
+ ? known(ORIENTATION_DEGREES[photo.orientation])
128
+ : unavailable(),
129
+ };
130
+ } finally {
131
+ // Large native buffers must not wait for the JS GC.
132
+ photo.dispose();
133
+ }
134
+ }
135
+
136
+ export const VisionCameraFeed: React.FC<VisionCameraFeedProps> = ({
137
+ cameraPosition,
138
+ resizeMode,
139
+ torchEnabled,
140
+ }) => {
141
+ const device = useCameraDevice(cameraPosition);
142
+ const photoOutput = usePhotoOutput({ quality: DEFAULT_QUALITY });
143
+
144
+ const surface = useMemo(
145
+ () => ({
146
+ capture: (request: CaptureRequest) =>
147
+ capturePhoto(photoOutput, request, cameraPosition),
148
+ }),
149
+ [photoOutput, cameraPosition],
150
+ );
151
+
152
+ useEffect(() => registerVisionSurface(surface), [surface]);
153
+
154
+ // VisionCamera applies `torchMode` on mount, before the camera is active, and
155
+ // leaves the native "Camera is not active" rejection unhandled — one red
156
+ // error per session start. Leave the prop unset until the torch is first
157
+ // turned on; after that 'off' must be sent explicitly to turn it back off.
158
+ const [torchTouched, setTorchTouched] = useState(false);
159
+ if (torchEnabled && !torchTouched) {
160
+ setTorchTouched(true);
161
+ }
162
+ const torchMode = torchEnabled ? 'on' : torchTouched ? 'off' : undefined;
163
+
164
+ if (!device) {
165
+ return null;
166
+ }
167
+
168
+ return (
169
+ <Camera
170
+ style={styles.feed}
171
+ device={device}
172
+ outputs={[photoOutput]}
173
+ isActive={true}
174
+ resizeMode={resizeMode}
175
+ torchMode={torchMode}
176
+ />
177
+ );
178
+ };
@@ -6,7 +6,7 @@
6
6
  * away (FR-016).
7
7
  */
8
8
 
9
- import { useEffect, useMemo, useRef, useState } from 'react';
9
+ import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
10
10
 
11
11
  import type { CameraState } from '../config/types';
12
12
  import {
@@ -21,10 +21,23 @@ export function useCameraController(
21
21
  // Options usually arrive as a fresh object literal each render; capturing
22
22
  // them in a ref keeps the controller stable for the component's lifetime.
23
23
  const optionsRef = useRef(options);
24
- const controller = useMemo(() => createCameraController(optionsRef.current), []);
24
+ // Construction has no side effects (the controller subscribes on first use),
25
+ // so a controller React builds and discards here costs nothing.
26
+ const [controller, setController] = useState(() => createCameraController(optionsRef.current));
27
+ const disposedRef = useRef(new WeakSet<CameraController>());
25
28
 
26
29
  useEffect(() => {
30
+ // StrictMode, and React's planned offscreen reuse, run the cleanup and then
31
+ // this effect again on the same component. Disposal is final, so the old
32
+ // controller cannot be revived; replace it. Before this, the screen kept a
33
+ // dead controller after the remount and the camera never started.
34
+ const disposed = disposedRef.current;
35
+ if (disposed.has(controller)) {
36
+ setController(createCameraController(optionsRef.current));
37
+ return undefined;
38
+ }
27
39
  return () => {
40
+ disposed.add(controller);
28
41
  controller.dispose().catch(() => undefined);
29
42
  };
30
43
  }, [controller]);
@@ -33,12 +46,13 @@ export function useCameraController(
33
46
  }
34
47
 
35
48
  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;
49
+ // `subscribe` is a property of the controller object, so it is stable for the
50
+ // controller's lifetime, which is what useSyncExternalStore requires. The
51
+ // store read is tear-free, unlike the previous useState copy, which could
52
+ // miss a change made between render and effect.
53
+ return useSyncExternalStore(
54
+ controller.subscribe,
55
+ () => controller.state,
56
+ () => controller.state,
57
+ );
44
58
  }
@@ -16,6 +16,14 @@ export interface ArtifactStore {
16
16
  remove(uri: string): Promise<void>;
17
17
  /** Deletes owned artifacts older than `olderThanMs`; returns the count. */
18
18
  sweep(olderThanMs: number): Promise<number>;
19
+ /**
20
+ * Brings a fresh capture under the store and returns its managed URI: a file
21
+ * already inside the store is recorded as created by this process, a file
22
+ * anywhere else is moved in. Without this, a capture the engine wrote to its
23
+ * own temporary directory is never owned, so `cleanup()` refuses it and the
24
+ * sweep never sees it. Optional so app-written stores keep compiling.
25
+ */
26
+ adopt?(uri: string): Promise<string>;
19
27
  }
20
28
 
21
29
  /** Minimal filesystem surface the adapter needs. Injectable for tests. */
@@ -26,6 +34,12 @@ export interface FileSystemBridge {
26
34
  list(directory: string): Promise<readonly string[]>;
27
35
  createdAt(path: string): Promise<number>;
28
36
  ensureDirectory(directory: string): Promise<void>;
37
+ /**
38
+ * Moves a file. Needed by `adopt()` to pull a capture out of the engine's own
39
+ * temporary directory; a bridge without it can only adopt files already
40
+ * inside the store.
41
+ */
42
+ move?(from: string, to: string): Promise<void>;
29
43
  }
30
44
 
31
45
  export interface StoreOptions {
@@ -48,17 +62,19 @@ export function createArtifactStore(
48
62
  // Artifacts created by this process are never swept, no matter their age.
49
63
  const createdThisProcess = new Set<string>();
50
64
 
65
+ function owns(uri: string): boolean {
66
+ return normalize(uri).startsWith(`${normalizedRoot}/`);
67
+ }
68
+
51
69
  return {
52
70
  temporaryDirectory() {
53
71
  return root;
54
72
  },
55
73
 
56
- owns(uri) {
57
- return normalize(uri).startsWith(`${normalizedRoot}/`);
58
- },
74
+ owns,
59
75
 
60
76
  async remove(uri) {
61
- if (!this.owns(uri)) {
77
+ if (!owns(uri)) {
62
78
  return;
63
79
  }
64
80
  const path = normalize(uri);
@@ -88,6 +104,23 @@ export function createArtifactStore(
88
104
  }
89
105
  return removed;
90
106
  },
107
+
108
+ async adopt(uri) {
109
+ const path = normalize(uri);
110
+ if (owns(uri)) {
111
+ createdThisProcess.add(path);
112
+ return uri;
113
+ }
114
+ if (!fs.move) {
115
+ throw new Error('This FileSystemBridge has no move(), so it cannot adopt a capture');
116
+ }
117
+ await fs.ensureDirectory(root);
118
+ const name = path.slice(path.lastIndexOf('/') + 1) || `capture-${Date.now()}.jpg`;
119
+ const target = `${root}/${name}`;
120
+ await fs.move(path, target);
121
+ createdThisProcess.add(normalize(target));
122
+ return uri.startsWith('file://') ? `file://${normalize(target)}` : target;
123
+ },
91
124
  };
92
125
  }
93
126
 
@@ -55,6 +55,13 @@ export function createMemoryFileSystem(root = '/cache'): MemoryFileSystem {
55
55
  async ensureDirectory(directory) {
56
56
  directories.add(directory);
57
57
  },
58
+ async move(from, to) {
59
+ if (!files.has(from)) {
60
+ throw new Error('No such file');
61
+ }
62
+ files.set(to, files.get(from) ?? Date.now());
63
+ files.delete(from);
64
+ },
58
65
  seed(path, createdAtMs) {
59
66
  files.set(path, createdAtMs);
60
67
  },