@chipmobilesdk/rn-camera 0.3.0 → 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.
@@ -0,0 +1,242 @@
1
+ /**
2
+ * The session's own I/O: starting it, waiting for frames, releasing it, and
3
+ * following the app in and out of the foreground.
4
+ *
5
+ * Split out of `controller.ts`, which had grown to hold three jobs at once:
6
+ * publishing state, running the session, and serving the public API. The
7
+ * decisions still live in `sessionMachine` (pure); this is the part that
8
+ * actually awaits the engine, and the controller composes it.
9
+ *
10
+ * It reads and writes the controller's state through `SessionContext` rather
11
+ * than owning it: one snapshot reaches subscribers, whichever part of the
12
+ * controller changed it.
13
+ */
14
+
15
+ import type { StartOptions } from '../config/types';
16
+ import { isCameraError, type CameraErrorCode } from '../diagnostics/errors';
17
+ import type { CameraLogger } from '../diagnostics/logger';
18
+ import type { CameraEngine } from '../native/cameraEngine';
19
+ import type { SurfaceInfo } from '../native/visionSurface';
20
+ import type { SessionEvent, SessionSnapshot } from './sessionMachine';
21
+
22
+ /** What the runtime needs from the controller that owns the state. */
23
+ export interface SessionContext {
24
+ readonly engine: CameraEngine;
25
+ readonly logger: CameraLogger;
26
+ /** How long to wait for a preview surface to report frames. */
27
+ readonly startTimeoutMs: number;
28
+ readonly scheduler: {
29
+ setTimeout(callback: () => void, ms: number): unknown;
30
+ clearTimeout(handle: unknown): void;
31
+ };
32
+ session(): SessionSnapshot;
33
+ dispatch(event: SessionEvent): void;
34
+ emit(): void;
35
+ cameraPosition(): 'front' | 'back';
36
+ setCameraPosition(position: 'front' | 'back'): void;
37
+ torchEnabled(): boolean;
38
+ setTorchEnabled(enabled: boolean): void;
39
+ surfaceMounted(): boolean;
40
+ permissionGranted(): boolean;
41
+ disposed(): boolean;
42
+ }
43
+
44
+ export interface SessionRuntime {
45
+ /** Starts the engine and, where the surface owns the session, waits for frames. */
46
+ start(options?: StartOptions): Promise<void>;
47
+ /** Turns the torch off and releases the camera. Safe from any state. */
48
+ release(): Promise<void>;
49
+ /** Torch off, whatever the session is doing. */
50
+ forceTorchOff(): Promise<void>;
51
+ /** The app went to the background or came back. */
52
+ onAppState(status: 'active' | 'background'): void;
53
+ /** The preview reported frames; carries what the mounted device turned out to be. */
54
+ reportSurfaceStarted(info: SurfaceInfo): void;
55
+ /** The preview's own session failed, or the OS took the camera. */
56
+ reportSurfaceFailed(code: CameraErrorCode): void;
57
+ /** The engine lost the camera: another app, a call, a disconnected device. */
58
+ onInterruption(code: CameraErrorCode): void;
59
+ /** What the surface last reported, for the published state. */
60
+ surfaceInfo(): SurfaceInfo | null;
61
+ /** Set when the app foregrounds with a surface mounted, so the session restarts. */
62
+ armResumeOnForeground(): void;
63
+ /** A release still in flight, so a restart can wait for it. */
64
+ pending(): Promise<void> | undefined;
65
+ }
66
+
67
+ export function createSessionRuntime(context: SessionContext): SessionRuntime {
68
+ let surfaceInfo: SurfaceInfo | null = null;
69
+ /** Resolved by the surface's own report, or by the start timeout. */
70
+ let pendingStart: { settle(): void; fail(code: CameraErrorCode): void } | null = null;
71
+ let appStatus: 'active' | 'background' = 'active';
72
+ let resumeOnForeground = false;
73
+ let releasing: Promise<void> | undefined;
74
+
75
+ /** Torch must never survive leaving the active state (FR-034b). */
76
+ async function forceTorchOff(): Promise<void> {
77
+ if (!context.torchEnabled()) {
78
+ return;
79
+ }
80
+ context.setTorchEnabled(false);
81
+ try {
82
+ await context.engine.setTorch(false);
83
+ } catch {
84
+ // Torch-off is best effort; the session is going away regardless.
85
+ }
86
+ context.emit();
87
+ }
88
+
89
+ async function release(): Promise<void> {
90
+ await forceTorchOff();
91
+ // Nothing to release. Going through STOP/STOPPED here turned an idle
92
+ // controller into `stopped` on every preview unmount.
93
+ const status = context.session().status;
94
+ if (status === 'idle' || status === 'stopped') {
95
+ return;
96
+ }
97
+ context.dispatch({ type: 'STOP' });
98
+ try {
99
+ await context.engine.stop();
100
+ context.dispatch({ type: 'STOPPED' });
101
+ } catch (error) {
102
+ context.dispatch({
103
+ type: 'FAIL',
104
+ code: isCameraError(error) ? error.code : 'captureFailed',
105
+ });
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Waits for the mounted preview to report frames.
111
+ *
112
+ * `engine.start()` only confirms a device exists for the VisionCamera
113
+ * adapter: the session belongs to the `<Camera>` view. Calling the session
114
+ * active there made `capture()` right after `start()` fail with `notReady`,
115
+ * and a camera that never produced a frame left the session in `starting`
116
+ * forever (CAM-08).
117
+ */
118
+ function awaitSurfaceStart(): Promise<void> {
119
+ return new Promise<void>(resolve => {
120
+ const timer = context.scheduler.setTimeout(() => {
121
+ if (pendingStart) {
122
+ pendingStart = null;
123
+ context.dispatch({ type: 'START_TIMEOUT' });
124
+ resolve();
125
+ }
126
+ }, context.startTimeoutMs);
127
+ const finish = () => {
128
+ context.scheduler.clearTimeout(timer);
129
+ pendingStart = null;
130
+ resolve();
131
+ };
132
+ pendingStart = {
133
+ settle: () => {
134
+ context.dispatch({ type: 'STARTED' });
135
+ finish();
136
+ },
137
+ fail: code => {
138
+ context.dispatch({ type: 'FAIL', code });
139
+ finish();
140
+ },
141
+ };
142
+ });
143
+ }
144
+
145
+ async function start(options: StartOptions = {}): Promise<void> {
146
+ try {
147
+ await context.engine.start({
148
+ cameraPosition: options.cameraPosition ?? context.cameraPosition(),
149
+ });
150
+ } catch (error) {
151
+ context.dispatch({
152
+ type: 'FAIL',
153
+ code: isCameraError(error) ? error.code : 'cameraUnavailable',
154
+ });
155
+ return;
156
+ }
157
+ if (context.engine.awaitsSurfaceStart !== true || !context.surfaceMounted()) {
158
+ // The engine owns its session, or there is no preview to wait for.
159
+ context.dispatch({ type: 'STARTED' });
160
+ return;
161
+ }
162
+ await awaitSurfaceStart();
163
+ }
164
+
165
+ function resumeAfterForeground(): void {
166
+ if (context.disposed() || appStatus !== 'active' || !resumeOnForeground) {
167
+ return;
168
+ }
169
+ resumeOnForeground = false;
170
+ if (!context.surfaceMounted() || !context.permissionGranted()) {
171
+ return;
172
+ }
173
+ context.dispatch({ type: 'FOREGROUND', surfaceMounted: true });
174
+ if (context.session().status === 'starting') {
175
+ start().catch(() => undefined);
176
+ }
177
+ }
178
+
179
+ return {
180
+ start,
181
+ release,
182
+ forceTorchOff,
183
+
184
+ onAppState(status) {
185
+ appStatus = status;
186
+ if (status === 'background') {
187
+ const running = context.session().status;
188
+ resumeOnForeground ||= running === 'active' || running === 'starting';
189
+ context.dispatch({ type: 'BACKGROUND' });
190
+ releasing = release().catch(() => undefined);
191
+ } else {
192
+ // Wait for a release still in flight, or the restart lands on a
193
+ // `stopping` session and is dropped.
194
+ (releasing ?? Promise.resolve()).then(resumeAfterForeground).catch(() => undefined);
195
+ }
196
+ },
197
+
198
+ reportSurfaceStarted(info) {
199
+ surfaceInfo = info;
200
+ if (info.cameraPosition !== context.cameraPosition()) {
201
+ // The device that mounted is the one taking the photos.
202
+ context.setCameraPosition(info.cameraPosition);
203
+ }
204
+ if (pendingStart) {
205
+ pendingStart.settle();
206
+ } else if (context.session().status === 'starting') {
207
+ context.dispatch({ type: 'STARTED' });
208
+ } else {
209
+ context.emit();
210
+ }
211
+ },
212
+
213
+ reportSurfaceFailed(code) {
214
+ if (pendingStart) {
215
+ pendingStart.fail(code);
216
+ return;
217
+ }
218
+ context.dispatch({ type: 'FAIL', code });
219
+ },
220
+
221
+ onInterruption(code) {
222
+ context.logger.log({ type: 'session', code });
223
+ if (pendingStart) {
224
+ pendingStart.fail(code);
225
+ return;
226
+ }
227
+ const status = context.session().status;
228
+ if (status !== 'idle' && status !== 'stopped') {
229
+ context.dispatch({ type: 'FAIL', code });
230
+ release().catch(() => undefined);
231
+ }
232
+ },
233
+
234
+ surfaceInfo: () => surfaceInfo,
235
+
236
+ armResumeOnForeground() {
237
+ resumeOnForeground = false;
238
+ },
239
+
240
+ pending: () => releasing,
241
+ };
242
+ }
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, EngineOptions } from './native/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';
@@ -21,8 +21,9 @@ import type {
21
21
  CaptureRequest,
22
22
  StartOptions,
23
23
  } from '../config/types';
24
+ import type { CameraErrorCode } from '../diagnostics/errors';
24
25
  import { CameraError } from '../diagnostics/errors';
25
- import { getActiveVisionSurface } from './visionSurface';
26
+ import { createSurfaceRegistry, type VisionSurface } from './visionSurface';
26
27
 
27
28
  export interface CameraEngine {
28
29
  start(options: StartOptions): Promise<void>;
@@ -30,11 +31,24 @@ export interface CameraEngine {
30
31
  capture(request: CaptureRequest): Promise<CapturedImage>;
31
32
  setTorch(enabled: boolean): Promise<void>;
32
33
  getCapabilities(): Promise<CapabilityProfile>;
33
- }
34
-
35
- export interface EngineOptions {
36
- /** Overrides device discovery. Primarily an escape hatch for diagnostics. */
37
- readonly preferredPosition?: 'front' | 'back';
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;
38
52
  }
39
53
 
40
54
  /** The subset of VisionCamera's `CameraDevice` this adapter reads. */
@@ -49,7 +63,9 @@ interface DiscoveredDevice {
49
63
  * component, so this adapter owns capability discovery while `CameraPreview`
50
64
  * owns the rendered surface and the photo output that capture runs through.
51
65
  */
52
- export function createVisionCameraEngine(_options: EngineOptions = {}): CameraEngine {
66
+ export function createVisionCameraEngine(): CameraEngine {
67
+ // One registry per engine, so two controllers never share a surface.
68
+ const surfaces = createSurfaceRegistry();
53
69
  // Loaded when the default adapter is created; a missing peer is `cameraUnavailable`.
54
70
  const { VisionCamera } = loadVisionCamera<{
55
71
  VisionCamera: {
@@ -67,6 +83,10 @@ export function createVisionCameraEngine(_options: EngineOptions = {}): CameraEn
67
83
  }
68
84
 
69
85
  return {
86
+ awaitsSurfaceStart: true,
87
+
88
+ attachSurface: surface => surfaces.attach(surface),
89
+
70
90
  async start() {
71
91
  // The preview component owns session activation; the engine only needs
72
92
  // to confirm a device exists before the surface mounts.
@@ -81,7 +101,7 @@ export function createVisionCameraEngine(_options: EngineOptions = {}): CameraEn
81
101
  },
82
102
 
83
103
  async capture(request: CaptureRequest) {
84
- const surface = getActiveVisionSurface();
104
+ const surface = surfaces.current();
85
105
  if (!surface) {
86
106
  throw new CameraError(
87
107
  'notReady',
@@ -1,34 +1,59 @@
1
1
  /**
2
- * Bridge between the mounted VisionCamera preview surface and the default
3
- * engine adapter.
2
+ * The port between a mounted preview surface and an engine.
4
3
  *
5
- * VisionCamera v5 owns capture through the session that the `<Camera>` view
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 by the app before any surface exists, so the surface
8
- * registers itself here on mount and the engine reads it back at capture time.
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
- * A single registration slot is deliberate: one camera session can be active at
11
- * a time, so a second surface replacing the first is the correct behaviour.
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
- let active: VisionSurface | null = null;
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
- /** Registers the mounted surface. Returns an unregister function. */
23
- export function registerVisionSurface(surface: VisionSurface): () => void {
24
- active = surface;
25
- return () => {
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 getActiveVisionSurface(): VisionSurface | null {
33
- return active;
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
  }
@@ -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
 
@@ -36,7 +38,15 @@ export interface CameraPreviewProps {
36
38
  /** The feed module failed to load; distinct from `null`, which means not requested. */
37
39
  const FEED_UNAVAILABLE = 'unavailable' as const;
38
40
 
39
- /** Nominal capture dimensions until the engine reports the real format. */
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
+ */
40
50
  const DEFAULT_IMAGE_WIDTH = 1920;
41
51
  const DEFAULT_IMAGE_HEIGHT = 1080;
42
52
 
@@ -66,6 +76,19 @@ export const CameraPreview: React.FC<CameraPreviewProps> = ({
66
76
  };
67
77
  }, [controller]);
68
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
+
69
92
  const onLayout = useCallback((event: LayoutChangeEvent) => {
70
93
  const { width, height } = event.nativeEvent.layout;
71
94
  setLayout({ width, height });
@@ -75,16 +98,17 @@ export const CameraPreview: React.FC<CameraPreviewProps> = ({
75
98
  if (!layout || layout.width === 0 || layout.height === 0) {
76
99
  return null;
77
100
  }
101
+ const surface = state.surface;
78
102
  return {
79
- imageWidth: DEFAULT_IMAGE_WIDTH,
80
- 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,
81
105
  previewWidth: layout.width,
82
106
  previewHeight: layout.height,
83
107
  resizeMode,
84
- orientation: 0,
85
- isMirrored: state.cameraPosition === 'front',
108
+ orientation: surface?.orientation ?? 0,
109
+ isMirrored: (surface?.cameraPosition ?? state.cameraPosition) === 'front',
86
110
  };
87
- }, [layout, resizeMode, state.cameraPosition]);
111
+ }, [layout, resizeMode, state.cameraPosition, state.surface]);
88
112
 
89
113
  useEffect(() => {
90
114
  if (geometry && onGeometryChange) {
@@ -92,7 +116,12 @@ export const CameraPreview: React.FC<CameraPreviewProps> = ({
92
116
  }
93
117
  }, [geometry, onGeometryChange]);
94
118
 
95
- const feedRequested = state.session === 'active' && state.permission === 'granted';
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');
96
125
 
97
126
  // Lazy require so importing this package never pulls in the native module
98
127
  // for consumers that only use the pure utilities or the test adapter.
@@ -129,6 +158,10 @@ export const CameraPreview: React.FC<CameraPreviewProps> = ({
129
158
  cameraPosition={state.cameraPosition}
130
159
  resizeMode={resizeMode}
131
160
  torchEnabled={state.torchEnabled}
161
+ isActive={state.session !== 'paused'}
162
+ attachSurface={attachSurface}
163
+ onStarted={onSurfaceStarted}
164
+ onFailed={onSurfaceFailed}
132
165
  />
133
166
  ) : null}
134
167
  {children}
@@ -30,13 +30,21 @@ import {
30
30
  type ResizeMode,
31
31
  } from '../config/types';
32
32
  import { DEFAULT_QUALITY } from '../core/captureController';
33
- import { CameraError, isCameraError } from '../diagnostics/errors';
34
- import { registerVisionSurface } from '../native/visionSurface';
33
+ import { CameraError, isCameraError, type CameraErrorCode } from '../diagnostics/errors';
34
+ import type { SurfaceInfo, VisionSurface } from '../native/visionSurface';
35
35
 
36
36
  export interface VisionCameraFeedProps {
37
37
  readonly cameraPosition: CameraPosition;
38
38
  readonly resizeMode: ResizeMode;
39
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;
40
48
  }
41
49
 
42
50
  const styles = StyleSheet.create({
@@ -71,6 +79,20 @@ function toFilePath(uri: string): string {
71
79
  return uri.startsWith('file://') ? uri.slice('file://'.length) : uri;
72
80
  }
73
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
+
74
96
  /** Native failures carry no code and may name a file path; never surface them raw. */
75
97
  function asCameraError(error: unknown, code: 'captureFailed' | 'storageFailure'): CameraError {
76
98
  return isCameraError(error) ? error : new CameraError(code);
@@ -79,6 +101,7 @@ function asCameraError(error: unknown, code: 'captureFailed' | 'storageFailure')
79
101
  async function capturePhoto(
80
102
  photoOutput: CameraPhotoOutput,
81
103
  request: CaptureRequest,
104
+ /** The device actually mounted; the request cannot ask for another one. */
82
105
  cameraPosition: CameraPosition,
83
106
  ): Promise<CapturedImage> {
84
107
  let photo: Awaited<ReturnType<CameraPhotoOutput['capturePhoto']>>;
@@ -117,7 +140,8 @@ async function capturePhoto(
117
140
  width: photo.width,
118
141
  height: photo.height,
119
142
  timestamp: Date.now(),
120
- cameraPosition: request.cameraPosition ?? cameraPosition,
143
+ // The camera that took it, not the one the request named (CAM-06).
144
+ cameraPosition,
121
145
  // Ownership is decided by the controller from the original request;
122
146
  // this value is overwritten there and is only a safe default.
123
147
  ownership: request.destination ? 'appOwned' : 'packageTemporary',
@@ -136,10 +160,37 @@ async function capturePhoto(
136
160
  }
137
161
  }
138
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
+
139
186
  export const VisionCameraFeed: React.FC<VisionCameraFeedProps> = ({
140
187
  cameraPosition,
141
188
  resizeMode,
142
189
  torchEnabled,
190
+ isActive,
191
+ attachSurface,
192
+ onStarted,
193
+ onFailed,
143
194
  }) => {
144
195
  const device = useCameraDevice(cameraPosition);
145
196
  const photoOutput = usePhotoOutput({ quality: DEFAULT_QUALITY });
@@ -151,7 +202,7 @@ export const VisionCameraFeed: React.FC<VisionCameraFeedProps> = ({
151
202
  [photoOutput, cameraPosition],
152
203
  );
153
204
 
154
- useEffect(() => registerVisionSurface(surface), [surface]);
205
+ useEffect(() => attachSurface(surface), [attachSurface, surface]);
155
206
 
156
207
  // VisionCamera applies `torchMode` on mount, before the camera is active, and
157
208
  // leaves the native "Camera is not active" rejection unhandled — one red
@@ -172,7 +223,12 @@ export const VisionCameraFeed: React.FC<VisionCameraFeedProps> = ({
172
223
  style={styles.feed}
173
224
  device={device}
174
225
  outputs={[photoOutput]}
175
- isActive={true}
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))}
176
232
  resizeMode={resizeMode}
177
233
  // Left unset until the app first touches the torch, so the device's own default stands.
178
234
  {...(torchMode === undefined ? {} : { torchMode })}
@@ -15,7 +15,7 @@ import {
15
15
  type CameraControllerOptions,
16
16
  } from '../core/controller';
17
17
 
18
- export function useCameraController(options: CameraControllerOptions = {}): CameraController {
18
+ export function useCameraController(options: CameraControllerOptions): CameraController {
19
19
  // Options usually arrive as a fresh object literal each render; capturing
20
20
  // them in a ref keeps the controller stable for the component's lifetime.
21
21
  const optionsRef = useRef(options);
@@ -7,6 +7,8 @@
7
7
  * the package can refuse to delete anything it did not create (FR-028).
8
8
  */
9
9
 
10
+ import { CameraError } from '../diagnostics/errors';
11
+
10
12
  export interface ArtifactStore {
11
13
  /** Absolute path of the package-owned temporary directory. */
12
14
  temporaryDirectory(): string;
@@ -112,7 +114,10 @@ export function createArtifactStore(
112
114
  return uri;
113
115
  }
114
116
  if (!fs.move) {
115
- throw new Error('This FileSystemBridge has no move(), so it cannot adopt a capture');
117
+ throw new CameraError(
118
+ 'storageFailure',
119
+ 'This FileSystemBridge has no move(), so it cannot adopt a capture',
120
+ );
116
121
  }
117
122
  await fs.ensureDirectory(root);
118
123
  const name = path.slice(path.lastIndexOf('/') + 1) || `capture-${Date.now()}.jpg`;
@@ -132,7 +137,9 @@ export function createArtifactStore(
132
137
  export function createCacheArtifactStore(options: StoreOptions = {}): ArtifactStore {
133
138
  const fs = options.fs;
134
139
  if (!fs) {
135
- throw new Error(
140
+ // A typed error, like every other failure this package reports.
141
+ throw new CameraError(
142
+ 'storageFailure',
136
143
  'createCacheArtifactStore requires a FileSystemBridge. Pass `fs`, or use ' +
137
144
  'createTestCamera() in tests.',
138
145
  );