@chipmobilesdk/rn-camera 0.2.1 → 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.
@@ -6,6 +6,8 @@
6
6
  * payloads — `redact` is applied before any value reaches a log sink.
7
7
  */
8
8
 
9
+ import { COMMON_PATTERNS, PATH_PATTERN, SdkError, redactPatterns } from '@chipmobilesdk/rn-core';
10
+
9
11
  export type CameraErrorCode =
10
12
  | 'permissionDenied'
11
13
  | 'cameraUnavailable'
@@ -32,16 +34,16 @@ const RECOVERABLE: ReadonlySet<CameraErrorCode> = new Set<CameraErrorCode>([
32
34
  'interrupted',
33
35
  ]);
34
36
 
35
- export class CameraError extends Error {
36
- readonly code: CameraErrorCode;
37
- readonly recoverable: boolean;
38
-
37
+ /** The package's error; an `SdkError` (`@chipmobilesdk/rn-core`) with `pkg: 'rn-camera'`. */
38
+ export class CameraError extends SdkError<CameraErrorCode> {
39
39
  constructor(code: CameraErrorCode, message?: string) {
40
- super(message ?? code);
40
+ super('rn-camera', code, { message, retryable: RECOVERABLE.has(code) });
41
41
  this.name = 'CameraError';
42
- this.code = code;
43
- this.recoverable = RECOVERABLE.has(code);
44
- Object.setPrototypeOf(this, CameraError.prototype);
42
+ }
43
+
44
+ /** @deprecated Use `retryable`, the name every SDK package shares. */
45
+ get recoverable(): boolean {
46
+ return this.retryable;
45
47
  }
46
48
  }
47
49
 
@@ -49,17 +51,17 @@ export function isCameraError(value: unknown): value is CameraError {
49
51
  return value instanceof CameraError;
50
52
  }
51
53
 
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;
54
+ /**
55
+ * rn-core's shared patterns (URLs, including file URIs; JWTs; e-mail addresses;
56
+ * UUIDs; base64-ish payload runs) plus POSIX and Windows paths.
57
+ */
58
+ const PATTERNS = [...COMMON_PATTERNS, PATH_PATTERN];
57
59
 
58
60
  const REDACTED = '[redacted]';
59
61
  const SENSITIVE_KEYS = new Set(['uri', 'path', 'destination', 'file', 'data', 'base64']);
60
62
 
61
63
  function redactString(value: string): string {
62
- return value.replace(PATH_PATTERN, REDACTED).replace(PAYLOAD_PATTERN, REDACTED);
64
+ return redactPatterns(value, PATTERNS);
63
65
  }
64
66
 
65
67
  /**
@@ -76,7 +78,7 @@ export function redact(value: unknown): unknown {
76
78
  return value.map(redact);
77
79
  }
78
80
  if (value instanceof CameraError) {
79
- return { code: value.code, recoverable: value.recoverable };
81
+ return { code: value.code, recoverable: value.retryable };
80
82
  }
81
83
  if (value instanceof Error) {
82
84
  return { name: value.name, message: redactString(value.message) };
@@ -6,18 +6,39 @@
6
6
  * image data — so an opted-in logger still cannot leak an image path.
7
7
  */
8
8
 
9
+ import { createDiagnosticChannel } from '@chipmobilesdk/rn-core';
10
+
9
11
  export type CameraLogEventType = 'session' | 'permission' | 'capture' | 'capability' | 'cleanup';
10
12
 
11
13
  export interface CameraLogEvent {
12
14
  readonly type: CameraLogEventType;
13
15
  readonly code: string;
14
- readonly recoverable?: boolean;
16
+ readonly recoverable?: boolean | undefined;
15
17
  }
16
18
 
17
19
  export interface CameraLogger {
18
20
  log(event: CameraLogEvent): void;
19
21
  }
20
22
 
23
+ const channel = createDiagnosticChannel('rn-camera');
24
+
25
+ /**
26
+ * Every event also reaches the app's SDK sinks (`installSdkSink`, CORE-08), as
27
+ * `<type>.<code>`: the same code-and-state body, so nothing new can leak.
28
+ */
29
+ export function withSdkSinks(logger: CameraLogger): CameraLogger {
30
+ return {
31
+ log(event) {
32
+ channel.emit(
33
+ `${event.type}.${event.code}`,
34
+ event.code === 'listener-threw' ? 'error' : event.recoverable === false ? 'warn' : 'info',
35
+ event.recoverable === undefined ? undefined : { retryable: event.recoverable },
36
+ );
37
+ logger.log(event);
38
+ },
39
+ };
40
+ }
41
+
21
42
  /** No-op logger used when the consumer supplies none. */
22
43
  export const silentLogger: CameraLogger = {
23
44
  log() {
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';
@@ -14,14 +14,16 @@
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,
20
21
  CaptureRequest,
21
22
  StartOptions,
22
23
  } from '../config/types';
24
+ import type { CameraErrorCode } from '../diagnostics/errors';
23
25
  import { CameraError } from '../diagnostics/errors';
24
- import { getActiveVisionSurface } from './visionSurface';
26
+ import { createSurfaceRegistry, type VisionSurface } from './visionSurface';
25
27
 
26
28
  export interface CameraEngine {
27
29
  start(options: StartOptions): Promise<void>;
@@ -29,11 +31,24 @@ export interface CameraEngine {
29
31
  capture(request: CaptureRequest): Promise<CapturedImage>;
30
32
  setTorch(enabled: boolean): Promise<void>;
31
33
  getCapabilities(): Promise<CapabilityProfile>;
32
- }
33
-
34
- export interface EngineOptions {
35
- /** Overrides device discovery. Primarily an escape hatch for diagnostics. */
36
- 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;
37
52
  }
38
53
 
39
54
  /** The subset of VisionCamera's `CameraDevice` this adapter reads. */
@@ -48,9 +63,15 @@ interface DiscoveredDevice {
48
63
  * component, so this adapter owns capability discovery while `CameraPreview`
49
64
  * owns the rendered surface and the photo output that capture runs through.
50
65
  */
51
- 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');
66
+ export function createVisionCameraEngine(): CameraEngine {
67
+ // One registry per engine, so two controllers never share a surface.
68
+ const surfaces = createSurfaceRegistry();
69
+ // Loaded when the default adapter is created; a missing peer is `cameraUnavailable`.
70
+ const { VisionCamera } = loadVisionCamera<{
71
+ VisionCamera: {
72
+ createDeviceFactory(): Promise<{ readonly cameraDevices: DiscoveredDevice[] }>;
73
+ };
74
+ }>();
54
75
 
55
76
  // The factory is created once and reused: it keeps its own device list up to
56
77
  // date, so re-creating it per call would only add latency.
@@ -62,6 +83,10 @@ export function createVisionCameraEngine(_options: EngineOptions = {}): CameraEn
62
83
  }
63
84
 
64
85
  return {
86
+ awaitsSurfaceStart: true,
87
+
88
+ attachSurface: surface => surfaces.attach(surface),
89
+
65
90
  async start() {
66
91
  // The preview component owns session activation; the engine only needs
67
92
  // to confirm a device exists before the surface mounts.
@@ -76,7 +101,7 @@ export function createVisionCameraEngine(_options: EngineOptions = {}): CameraEn
76
101
  },
77
102
 
78
103
  async capture(request: CaptureRequest) {
79
- const surface = getActiveVisionSurface();
104
+ const surface = surfaces.current();
80
105
  if (!surface) {
81
106
  throw new CameraError(
82
107
  'notReady',
@@ -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
+ }
@@ -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
  }
@@ -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
  },
@@ -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
 
@@ -33,7 +35,18 @@ export interface CameraPreviewProps {
33
35
  readonly children?: React.ReactNode;
34
36
  }
35
37
 
36
- /** Nominal capture dimensions until the engine reports the real format. */
38
+ /** The feed module failed to load; distinct from `null`, which means not requested. */
39
+ const FEED_UNAVAILABLE = 'unavailable' as const;
40
+
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
+ */
37
50
  const DEFAULT_IMAGE_WIDTH = 1920;
38
51
  const DEFAULT_IMAGE_HEIGHT = 1080;
39
52
 
@@ -63,6 +76,19 @@ export const CameraPreview: React.FC<CameraPreviewProps> = ({
63
76
  };
64
77
  }, [controller]);
65
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
+
66
92
  const onLayout = useCallback((event: LayoutChangeEvent) => {
67
93
  const { width, height } = event.nativeEvent.layout;
68
94
  setLayout({ width, height });
@@ -72,16 +98,17 @@ export const CameraPreview: React.FC<CameraPreviewProps> = ({
72
98
  if (!layout || layout.width === 0 || layout.height === 0) {
73
99
  return null;
74
100
  }
101
+ const surface = state.surface;
75
102
  return {
76
- imageWidth: DEFAULT_IMAGE_WIDTH,
77
- 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,
78
105
  previewWidth: layout.width,
79
106
  previewHeight: layout.height,
80
107
  resizeMode,
81
- orientation: 0,
82
- isMirrored: state.cameraPosition === 'front',
108
+ orientation: surface?.orientation ?? 0,
109
+ isMirrored: (surface?.cameraPosition ?? state.cameraPosition) === 'front',
83
110
  };
84
- }, [layout, resizeMode, state.cameraPosition]);
111
+ }, [layout, resizeMode, state.cameraPosition, state.surface]);
85
112
 
86
113
  useEffect(() => {
87
114
  if (geometry && onGeometryChange) {
@@ -89,7 +116,12 @@ export const CameraPreview: React.FC<CameraPreviewProps> = ({
89
116
  }
90
117
  }, [geometry, onGeometryChange]);
91
118
 
92
- 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');
93
125
 
94
126
  // Lazy require so importing this package never pulls in the native module
95
127
  // for consumers that only use the pure utilities or the test adapter.
@@ -101,10 +133,18 @@ export const CameraPreview: React.FC<CameraPreviewProps> = ({
101
133
  return require('./VisionCameraFeed')
102
134
  .VisionCameraFeed as React.ComponentType<VisionCameraFeedProps>;
103
135
  } catch {
104
- return null;
136
+ // Reported, not swallowed (CAM-21): the session fails with
137
+ // `cameraUnavailable` instead of showing a black preview.
138
+ return FEED_UNAVAILABLE;
105
139
  }
106
140
  }, [feedRequested]);
107
141
 
142
+ useEffect(() => {
143
+ if (Feed === FEED_UNAVAILABLE) {
144
+ controller.reportSurfaceUnavailable();
145
+ }
146
+ }, [Feed, controller]);
147
+
108
148
  return (
109
149
  <View
110
150
  style={style}
@@ -113,11 +153,15 @@ export const CameraPreview: React.FC<CameraPreviewProps> = ({
113
153
  accessibilityRole={accessibilityRole}
114
154
  testID="rn-camera-preview"
115
155
  >
116
- {Feed ? (
156
+ {Feed && Feed !== FEED_UNAVAILABLE ? (
117
157
  <Feed
118
158
  cameraPosition={state.cameraPosition}
119
159
  resizeMode={resizeMode}
120
160
  torchEnabled={state.torchEnabled}
161
+ isActive={state.session !== 'paused'}
162
+ attachSurface={attachSurface}
163
+ onStarted={onSurfaceStarted}
164
+ onFailed={onSurfaceFailed}
121
165
  />
122
166
  ) : null}
123
167
  {children}
@@ -26,16 +26,25 @@ import {
26
26
  type CameraPosition,
27
27
  type CapturedImage,
28
28
  type CaptureRequest,
29
+ type Maybe,
29
30
  type ResizeMode,
30
31
  } from '../config/types';
31
32
  import { DEFAULT_QUALITY } from '../core/captureController';
32
- import { CameraError, isCameraError } from '../diagnostics/errors';
33
- import { registerVisionSurface } from '../native/visionSurface';
33
+ import { CameraError, isCameraError, type CameraErrorCode } from '../diagnostics/errors';
34
+ import type { SurfaceInfo, VisionSurface } from '../native/visionSurface';
34
35
 
35
36
  export interface VisionCameraFeedProps {
36
37
  readonly cameraPosition: CameraPosition;
37
38
  readonly resizeMode: ResizeMode;
38
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;
39
48
  }
40
49
 
41
50
  const styles = StyleSheet.create({
@@ -57,6 +66,11 @@ const ORIENTATION_DEGREES: Record<string, number> = {
57
66
  left: 270,
58
67
  };
59
68
 
69
+ function orientationOf(orientation: string): Maybe<number> {
70
+ const degrees = ORIENTATION_DEGREES[orientation];
71
+ return degrees === undefined ? unavailable() : known(degrees);
72
+ }
73
+
60
74
  function toFileUri(path: string): string {
61
75
  return path.startsWith('file://') ? path : `file://${path}`;
62
76
  }
@@ -65,6 +79,20 @@ function toFilePath(uri: string): string {
65
79
  return uri.startsWith('file://') ? uri.slice('file://'.length) : uri;
66
80
  }
67
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
+
68
96
  /** Native failures carry no code and may name a file path; never surface them raw. */
69
97
  function asCameraError(error: unknown, code: 'captureFailed' | 'storageFailure'): CameraError {
70
98
  return isCameraError(error) ? error : new CameraError(code);
@@ -73,6 +101,7 @@ function asCameraError(error: unknown, code: 'captureFailed' | 'storageFailure')
73
101
  async function capturePhoto(
74
102
  photoOutput: CameraPhotoOutput,
75
103
  request: CaptureRequest,
104
+ /** The device actually mounted; the request cannot ask for another one. */
76
105
  cameraPosition: CameraPosition,
77
106
  ): Promise<CapturedImage> {
78
107
  let photo: Awaited<ReturnType<CameraPhotoOutput['capturePhoto']>>;
@@ -111,7 +140,8 @@ async function capturePhoto(
111
140
  width: photo.width,
112
141
  height: photo.height,
113
142
  timestamp: Date.now(),
114
- cameraPosition: request.cameraPosition ?? cameraPosition,
143
+ // The camera that took it, not the one the request named (CAM-06).
144
+ cameraPosition,
115
145
  // Ownership is decided by the controller from the original request;
116
146
  // this value is overwritten there and is only a safe default.
117
147
  ownership: request.destination ? 'appOwned' : 'packageTemporary',
@@ -122,10 +152,7 @@ async function capturePhoto(
122
152
  // The native layer reports neither back after a save, and guessing them
123
153
  // would be worse than saying so (FR-022).
124
154
  fileSize: unavailable(),
125
- orientation:
126
- photo.orientation in ORIENTATION_DEGREES
127
- ? known(ORIENTATION_DEGREES[photo.orientation])
128
- : unavailable(),
155
+ orientation: orientationOf(photo.orientation),
129
156
  };
130
157
  } finally {
131
158
  // Large native buffers must not wait for the JS GC.
@@ -133,10 +160,37 @@ async function capturePhoto(
133
160
  }
134
161
  }
135
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
+
136
186
  export const VisionCameraFeed: React.FC<VisionCameraFeedProps> = ({
137
187
  cameraPosition,
138
188
  resizeMode,
139
189
  torchEnabled,
190
+ isActive,
191
+ attachSurface,
192
+ onStarted,
193
+ onFailed,
140
194
  }) => {
141
195
  const device = useCameraDevice(cameraPosition);
142
196
  const photoOutput = usePhotoOutput({ quality: DEFAULT_QUALITY });
@@ -148,7 +202,7 @@ export const VisionCameraFeed: React.FC<VisionCameraFeedProps> = ({
148
202
  [photoOutput, cameraPosition],
149
203
  );
150
204
 
151
- useEffect(() => registerVisionSurface(surface), [surface]);
205
+ useEffect(() => attachSurface(surface), [attachSurface, surface]);
152
206
 
153
207
  // VisionCamera applies `torchMode` on mount, before the camera is active, and
154
208
  // leaves the native "Camera is not active" rejection unhandled — one red
@@ -169,9 +223,15 @@ export const VisionCameraFeed: React.FC<VisionCameraFeedProps> = ({
169
223
  style={styles.feed}
170
224
  device={device}
171
225
  outputs={[photoOutput]}
172
- 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))}
173
232
  resizeMode={resizeMode}
174
- torchMode={torchMode}
233
+ // Left unset until the app first touches the torch, so the device's own default stands.
234
+ {...(torchMode === undefined ? {} : { torchMode })}
175
235
  />
176
236
  );
177
237
  };