react-native-image-stitcher 0.13.0 → 0.14.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +115 -0
  2. package/README.md +238 -62
  3. package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +33 -5
  4. package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +73 -1
  5. package/dist/camera/Camera.d.ts +71 -16
  6. package/dist/camera/Camera.js +167 -51
  7. package/dist/camera/CameraView.d.ts +6 -0
  8. package/dist/camera/CameraView.js +2 -2
  9. package/dist/camera/CaptureHeader.js +39 -16
  10. package/dist/camera/CapturePreview.js +13 -1
  11. package/dist/camera/CaptureThumbnailStrip.d.ts +25 -1
  12. package/dist/camera/CaptureThumbnailStrip.js +17 -4
  13. package/dist/camera/PanoramaBandOverlay.d.ts +76 -0
  14. package/dist/camera/PanoramaBandOverlay.js +90 -33
  15. package/dist/camera/PanoramaConfirmModal.js +11 -1
  16. package/dist/camera/selectCaptureDevice.d.ts +93 -0
  17. package/dist/camera/selectCaptureDevice.js +131 -0
  18. package/dist/camera/useCapture.d.ts +40 -0
  19. package/dist/camera/useCapture.js +50 -12
  20. package/dist/camera/useContentRotation.d.ts +99 -0
  21. package/dist/camera/useContentRotation.js +124 -0
  22. package/dist/index.d.ts +1 -3
  23. package/dist/index.js +6 -5
  24. package/package.json +1 -1
  25. package/src/camera/Camera.tsx +281 -118
  26. package/src/camera/CameraView.tsx +9 -0
  27. package/src/camera/CaptureHeader.tsx +39 -16
  28. package/src/camera/CapturePreview.tsx +12 -0
  29. package/src/camera/CaptureThumbnailStrip.tsx +44 -4
  30. package/src/camera/PanoramaBandOverlay.tsx +97 -35
  31. package/src/camera/PanoramaConfirmModal.tsx +10 -0
  32. package/src/camera/__tests__/bandThumbRotation.test.ts +120 -0
  33. package/src/camera/__tests__/homeIndicatorEdge.test.ts +116 -0
  34. package/src/camera/__tests__/selectCaptureDevice.test.ts +177 -0
  35. package/src/camera/__tests__/useContentRotation.test.ts +89 -0
  36. package/src/camera/selectCaptureDevice.ts +187 -0
  37. package/src/camera/useCapture.ts +99 -11
  38. package/src/camera/useContentRotation.ts +149 -0
  39. package/src/index.ts +6 -2
@@ -82,6 +82,30 @@ export interface CaptureThumbnailStripProps {
82
82
  * stay under the strip's control to keep the count line consistent.
83
83
  */
84
84
  style?: StyleProp<ViewStyle>;
85
+ /**
86
+ * v0.13.1 — when `true`, the strip stacks thumbnails VERTICALLY
87
+ * (column, scrolls up/down) instead of the default horizontal row.
88
+ * `<Camera>` sets this from the same `isSideEdge(homeIndicatorEdge)`
89
+ * signal that drives PanoramaBandOverlay's `vertical`, so under a
90
+ * non-locked host in landscape the idle capture strip stacks along
91
+ * the home-indicator edge like the live band does (rather than
92
+ * running horizontally across the middle of the rotated screen).
93
+ * Default `false` (legacy horizontal strip) — unchanged for
94
+ * portrait-locked hosts.
95
+ */
96
+ vertical?: boolean;
97
+ /**
98
+ * v0.13.1 — counter-rotation applied to each thumbnail image so the
99
+ * captured scene reads upright when the device is held landscape
100
+ * under a PORTRAIT-LOCKED host (the JS framebuffer stays portrait, so
101
+ * the thumbnail would otherwise show 90° off). `<Camera>` passes the
102
+ * `useContentRotation()` result; `{}` (no-op) in upright cases.
103
+ * Applies only to the strip's own images — orientation of the strip's
104
+ * scroll axis is handled separately by `vertical`.
105
+ */
106
+ contentRotation?: {
107
+ transform?: ViewStyle['transform'];
108
+ };
85
109
  }
86
- export declare function CaptureThumbnailStrip({ items, minPhotos, maxPhotos, backgroundColor, textColor, successColor, warningColor, disablePreview, onItemPress, style, }: CaptureThumbnailStripProps): React.JSX.Element;
110
+ export declare function CaptureThumbnailStrip({ items, minPhotos, maxPhotos, backgroundColor, textColor, successColor, warningColor, disablePreview, onItemPress, style, vertical, contentRotation, }: CaptureThumbnailStripProps): React.JSX.Element;
87
111
  //# sourceMappingURL=CaptureThumbnailStrip.d.ts.map
@@ -91,7 +91,7 @@ function thumbWidth(item) {
91
91
  const computed = Math.round(THUMB_HEIGHT * ratio);
92
92
  return Math.max(THUMB_MIN_WIDTH, Math.min(THUMB_MAX_WIDTH, computed));
93
93
  }
94
- function CaptureThumbnailStrip({ items, minPhotos, maxPhotos, backgroundColor = 'rgba(0,0,0,0.85)', textColor = '#ffffff', successColor = '#34C759', warningColor = '#FF9F0A', disablePreview = false, onItemPress, style, }) {
94
+ function CaptureThumbnailStrip({ items, minPhotos, maxPhotos, backgroundColor = 'rgba(0,0,0,0.85)', textColor = '#ffffff', successColor = '#34C759', warningColor = '#FF9F0A', disablePreview = false, onItemPress, style, vertical = false, contentRotation, }) {
95
95
  // Built-in preview state — only used when the host hasn't
96
96
  // provided its own onItemPress handler. Letting the host pass a
97
97
  // handler is how the AuditCaptureScreen unifies thumbnail
@@ -124,16 +124,19 @@ function CaptureThumbnailStrip({ items, minPhotos, maxPhotos, backgroundColor =
124
124
  ], accessibilityLabel: `Captured ${items.length} photos` }, text));
125
125
  }, [items.length, minPhotos, maxPhotos, successColor, warningColor]);
126
126
  return (react_1.default.createElement(react_native_1.View, { style: [styles.root, { backgroundColor }, style] },
127
- react_1.default.createElement(react_native_1.FlatList, { data: items, horizontal: true, showsHorizontalScrollIndicator: false, keyExtractor: (item) => item.id, contentContainerStyle: styles.listContent, ListEmptyComponent: react_1.default.createElement(react_native_1.View, { style: [styles.placeholder, { borderColor: textColor }], accessibilityLabel: "No photos captured" },
127
+ react_1.default.createElement(react_native_1.FlatList, { data: items, horizontal: !vertical, showsHorizontalScrollIndicator: false, showsVerticalScrollIndicator: false, keyExtractor: (item) => item.id, contentContainerStyle: vertical ? styles.listContentVertical : styles.listContent, ListEmptyComponent: react_1.default.createElement(react_native_1.View, { style: [styles.placeholder, { borderColor: textColor }], accessibilityLabel: "No photos captured" },
128
128
  react_1.default.createElement(react_native_1.Text, { style: [styles.placeholderText, { color: textColor }] }, "No photos")), renderItem: ({ item }) => (react_1.default.createElement(react_native_1.Pressable, { onPress: () => handleItemPress(item), disabled: disablePreview, accessibilityRole: "imagebutton", accessibilityLabel: "Open preview",
129
129
  // Resolve the width per-item — done at render rather than
130
130
  // inside renderItem's style prop so the function isn't
131
131
  // re-created on every parent render.
132
132
  style: [
133
133
  styles.thumbWrapper,
134
+ // Spacing runs along the scroll axis: marginRight for the
135
+ // horizontal strip, marginBottom for the vertical column.
136
+ vertical ? styles.thumbWrapperVertical : styles.thumbWrapperHorizontal,
134
137
  { width: thumbWidth(item), height: THUMB_HEIGHT },
135
138
  ] },
136
- react_1.default.createElement(react_native_1.Image, { source: { uri: item.uri }, style: styles.thumbImage, resizeMode: "cover" }))) }),
139
+ react_1.default.createElement(react_native_1.Image, { source: { uri: item.uri }, style: [styles.thumbImage, contentRotation], resizeMode: "cover" }))) }),
137
140
  countLine,
138
141
  react_1.default.createElement(CapturePreview_1.CapturePreview, { visible: previewItem !== null, imageUri: previewItem?.uri ?? '', imageWidth: previewItem?.width, imageHeight: previewItem?.height, onClose: closePreview })));
139
142
  }
@@ -145,12 +148,22 @@ const styles = react_native_1.StyleSheet.create({
145
148
  paddingHorizontal: 12,
146
149
  alignItems: 'center',
147
150
  },
151
+ listContentVertical: {
152
+ paddingVertical: 12,
153
+ alignItems: 'center',
154
+ },
148
155
  thumbWrapper: {
149
- marginRight: 8,
150
156
  borderRadius: 4,
151
157
  overflow: 'hidden',
152
158
  backgroundColor: '#222',
153
159
  },
160
+ // Spacing applied along the scroll axis (see render site).
161
+ thumbWrapperHorizontal: {
162
+ marginRight: 8,
163
+ },
164
+ thumbWrapperVertical: {
165
+ marginBottom: 8,
166
+ },
154
167
  thumbImage: {
155
168
  width: '100%',
156
169
  height: '100%',
@@ -115,5 +115,81 @@ export interface PanoramaBandOverlayProps {
115
115
  */
116
116
  captureOrientation?: BandCaptureOrientation;
117
117
  }
118
+ /**
119
+ * Resolve band layout from capture orientation. 2026-05-18 (Issue #3)
120
+ * — uses the 4-way `BandCaptureOrientation` instead of the 2-way
121
+ * `state.isLandscape` so we can pick the right flex direction +
122
+ * arrow glyph in EACH landscape rotation.
123
+ *
124
+ * The two landscape rotations require different JS-coordinate setups
125
+ * because the phone tilts the JS coordinate system relative to the
126
+ * user differently:
127
+ *
128
+ * LANDSCAPE-LEFT (Apple: home indicator on user's RIGHT; phone
129
+ * rotated 90° CCW from portrait).
130
+ * JS-left = user-top
131
+ * JS-right = user-bottom
132
+ * Band at JS-bottom edge appears on user's RIGHT edge.
133
+ * For "oldest at user-top, newest at user-bottom":
134
+ * flexDirection = 'row' (array[0] at JS-left = user-top).
135
+ * For arrow appearing as user-DOWN-arrow:
136
+ * glyph `←` (rotated 90° CCW = points user-down).
137
+ *
138
+ * LANDSCAPE-RIGHT (Apple: home indicator on user's LEFT; phone
139
+ * rotated 90° CW from portrait).
140
+ * JS-left = user-bottom
141
+ * JS-right = user-top
142
+ * Band at JS-TOP edge appears on user's RIGHT edge (so we move
143
+ * the band to JS-top here, not JS-bottom).
144
+ * For "oldest at user-top, newest at user-bottom":
145
+ * flexDirection = 'row-reverse' (array[0] at JS-right = user-top).
146
+ * For arrow appearing as user-DOWN-arrow:
147
+ * glyph `→` (rotated 90° CW = points user-down).
148
+ *
149
+ * PORTRAIT (and portrait-upside-down — collapsed because the band's
150
+ * bottom-anchored position remains sensible either way):
151
+ * Band at JS-bottom = user-bottom. Row left-to-right. Arrow `→`
152
+ * reads as user-right-arrow (pointing along the horizontal pan
153
+ * direction).
154
+ */
155
+ /**
156
+ * v0.13.1 — pure rotation-decision helpers, extracted for unit testing
157
+ * (the lib's jest config is pure-TS, no component mounting; see
158
+ * jest.config.js). These encode the orientation contract the band
159
+ * relies on, so a regression in the angles/branches is caught in CI
160
+ * rather than only on-device.
161
+ *
162
+ * `bandThumbRotation` — the CSS rotate transform that aligns a thumb's
163
+ * pixels with the band box. Returns the transform array RN expects, or
164
+ * `undefined` for "no rotation". Two regimes:
165
+ * - vertical=false (portrait-locked UI): the box is device-aligned, so
166
+ * a landscape device needs a 90° counter-rotation (CW for
167
+ * landscape-left, CCW for landscape-right).
168
+ * - vertical=true (non-locked, OS-rotated framebuffer): the screen
169
+ * rotation already did half the work, so the compensation is the
170
+ * OPPOSITE sign.
171
+ * Exported as `_bandThumbRotationForTests`.
172
+ */
173
+ declare function bandThumbRotation(orientation: BandCaptureOrientation, vertical: boolean): Array<{
174
+ rotate: string;
175
+ }> | undefined;
176
+ /**
177
+ * v0.13.1 — the rotation actually applied to the per-keyframe (multi-
178
+ * thumb) TILES. This is the EXIF double-rotation fix: the saved
179
+ * `keyframe-N.jpg` is sensor-native landscape + EXIF Orientation 6, which
180
+ * RN's <Image> already auto-rotates upright. So in the portrait-locked
181
+ * (vertical=false) path NO further transform is applied — adding one
182
+ * double-rotates (the original v0.12 bug). Only the non-locked
183
+ * (vertical=true) path needs the compensation. Returns `undefined` for
184
+ * "no transform". Exported as `_tileRotationForTests`.
185
+ */
186
+ declare function tileRotation(orientation: BandCaptureOrientation, vertical: boolean): Array<{
187
+ rotate: string;
188
+ }> | undefined;
189
+ /** @internal test-only export — see `bandThumbRotation`. */
190
+ export declare const _bandThumbRotationForTests: typeof bandThumbRotation;
191
+ /** @internal test-only export — see `tileRotation`. */
192
+ export declare const _tileRotationForTests: typeof tileRotation;
118
193
  export declare function PanoramaBandOverlay({ state, frameUris, captureOrientation, vertical, }: PanoramaBandOverlayProps): React.JSX.Element | null;
194
+ export {};
119
195
  //# sourceMappingURL=PanoramaBandOverlay.d.ts.map
@@ -92,6 +92,7 @@ var __importStar = (this && this.__importStar) || (function () {
92
92
  };
93
93
  })();
94
94
  Object.defineProperty(exports, "__esModule", { value: true });
95
+ exports._tileRotationForTests = exports._bandThumbRotationForTests = void 0;
95
96
  exports.PanoramaBandOverlay = PanoramaBandOverlay;
96
97
  const react_1 = __importStar(require("react"));
97
98
  const react_native_1 = require("react-native");
@@ -141,6 +142,55 @@ const MULTI_THUMB_HARD_CAP = 32; // safety net; host typically caps at 24
141
142
  * reads as user-right-arrow (pointing along the horizontal pan
142
143
  * direction).
143
144
  */
145
+ /**
146
+ * v0.13.1 — pure rotation-decision helpers, extracted for unit testing
147
+ * (the lib's jest config is pure-TS, no component mounting; see
148
+ * jest.config.js). These encode the orientation contract the band
149
+ * relies on, so a regression in the angles/branches is caught in CI
150
+ * rather than only on-device.
151
+ *
152
+ * `bandThumbRotation` — the CSS rotate transform that aligns a thumb's
153
+ * pixels with the band box. Returns the transform array RN expects, or
154
+ * `undefined` for "no rotation". Two regimes:
155
+ * - vertical=false (portrait-locked UI): the box is device-aligned, so
156
+ * a landscape device needs a 90° counter-rotation (CW for
157
+ * landscape-left, CCW for landscape-right).
158
+ * - vertical=true (non-locked, OS-rotated framebuffer): the screen
159
+ * rotation already did half the work, so the compensation is the
160
+ * OPPOSITE sign.
161
+ * Exported as `_bandThumbRotationForTests`.
162
+ */
163
+ function bandThumbRotation(orientation, vertical) {
164
+ if (vertical) {
165
+ if (orientation === 'landscape-left')
166
+ return [{ rotate: '-90deg' }];
167
+ if (orientation === 'landscape-right')
168
+ return [{ rotate: '90deg' }];
169
+ return undefined;
170
+ }
171
+ if (orientation === 'landscape-left')
172
+ return [{ rotate: '90deg' }];
173
+ if (orientation === 'landscape-right')
174
+ return [{ rotate: '-90deg' }];
175
+ return undefined;
176
+ }
177
+ /**
178
+ * v0.13.1 — the rotation actually applied to the per-keyframe (multi-
179
+ * thumb) TILES. This is the EXIF double-rotation fix: the saved
180
+ * `keyframe-N.jpg` is sensor-native landscape + EXIF Orientation 6, which
181
+ * RN's <Image> already auto-rotates upright. So in the portrait-locked
182
+ * (vertical=false) path NO further transform is applied — adding one
183
+ * double-rotates (the original v0.12 bug). Only the non-locked
184
+ * (vertical=true) path needs the compensation. Returns `undefined` for
185
+ * "no transform". Exported as `_tileRotationForTests`.
186
+ */
187
+ function tileRotation(orientation, vertical) {
188
+ return vertical ? bandThumbRotation(orientation, vertical) : undefined;
189
+ }
190
+ /** @internal test-only export — see `bandThumbRotation`. */
191
+ exports._bandThumbRotationForTests = bandThumbRotation;
192
+ /** @internal test-only export — see `tileRotation`. */
193
+ exports._tileRotationForTests = tileRotation;
144
194
  function layoutFor(orientation, vertical) {
145
195
  const commonInner = {
146
196
  alignItems: 'center',
@@ -326,42 +376,49 @@ function PanoramaBandOverlay({ state, frameUris, captureOrientation, vertical =
326
376
  // transform, so they appeared sideways in portrait-locked
327
377
  // landscape captures (the case the example app's batch-keyframe
328
378
  // engine hits).
329
- const thumbRotationTransform = (0, react_1.useMemo)(() => {
330
- // Empirical observation (on-device test 2026-05-28): captured
331
- // per-keyframe JPEGs ARE saved in sensor-native landscape (not
332
- // user-perspective), despite the cumulative panorama getting
333
- // device-orientation rotation via finalize(). So:
334
- //
335
- // jsPortrait box + landscape device: box is device-aligned;
336
- // image's "up" is at file-right (sensor convention). Rotate
337
- // 90° CW (landscape-left) / 90° CCW (landscape-right) to
338
- // align image up with box up.
339
- // jsLandscape box + landscape device: box is user-aligned via
340
- // OS screen rotation; image's "up" still at file-right. To
341
- // align image up with box up, rotate the OPPOSITE direction
342
- // from the jsPortrait case — the screen-rotation already
343
- // handles half the work; we just need to compensate for the
344
- // remaining mismatch.
345
- if (vertical) {
346
- if (resolvedOrientation === 'landscape-left')
347
- return [{ rotate: '-90deg' }];
348
- if (resolvedOrientation === 'landscape-right')
349
- return [{ rotate: '90deg' }];
350
- return undefined;
351
- }
352
- if (resolvedOrientation === 'landscape-left')
353
- return [{ rotate: '90deg' }];
354
- if (resolvedOrientation === 'landscape-right')
355
- return [{ rotate: '-90deg' }];
356
- return undefined;
357
- }, [resolvedOrientation, vertical]);
379
+ // Rotation for the single cumulative thumb (panorama-*.jpg, a JFIF
380
+ // with NO EXIF tag → RN does not auto-rotate it, so the transform is
381
+ // always needed). See `bandThumbRotation` for the angle contract.
382
+ const thumbRotationTransform = (0, react_1.useMemo)(() => bandThumbRotation(resolvedOrientation, vertical), [resolvedOrientation, vertical]);
358
383
  const singleImageStyle = (0, react_1.useMemo)(() => thumbRotationTransform
359
384
  ? [react_native_1.StyleSheet.absoluteFill, { transform: thumbRotationTransform }]
360
385
  : react_native_1.StyleSheet.absoluteFill, [thumbRotationTransform]);
361
- // Same rotation applied to the per-keyframe (multi-thumb) tiles.
362
- const multiThumbStyle = (0, react_1.useMemo)(() => thumbRotationTransform
363
- ? [styles.multiThumb, { transform: thumbRotationTransform }]
364
- : styles.multiThumb, [thumbRotationTransform]);
386
+ // v0.13.1 per-keyframe tile rotation is conditional on `vertical`.
387
+ //
388
+ // The keyframe JPEGs (`keyframe-N.jpg`) are saved as sensor-native
389
+ // landscape PIXELS *plus* an EXIF Orientation tag (= 6, "rotate 90°
390
+ // CW for display") — verified on-device: Android SM-A356U1 640×480
391
+ // + EXIF6, iOS iPhone16Pro 1920×1080 + EXIF6. RN's <Image> (Fresco
392
+ // on Android, ImageIO on iOS) HONORS EXIF and auto-rotates each tile
393
+ // to gravity-upright on its own. Whether a *further* JS transform is
394
+ // needed depends on the band box's coordinate frame:
395
+ //
396
+ // vertical=false (portrait-locked UI): box is in portrait JS coords,
397
+ // which align with the EXIF-upright tile → NO transform. Applying
398
+ // one here double-rotates (the original v0.12 bug — tiles appeared
399
+ // 90° off in portrait-locked landscape captures). Verified fixed
400
+ // on Android portrait-lock.
401
+ // vertical=true (non-locked host, device-landscape): box is in
402
+ // landscape JS coords, rotated 90° from the EXIF-upright tile →
403
+ // the counter-rotation is STILL required (verified on iOS: with no
404
+ // transform the tiles sit 90° off).
405
+ //
406
+ // So reuse `thumbRotationTransform` (which already encodes the correct
407
+ // per-orientation angle) ONLY in the vertical=true branch.
408
+ //
409
+ // The single cumulative thumb above always needs the transform: its
410
+ // source (`panorama-*.jpg`) is a JFIF with NO EXIF tag (verified:
411
+ // header ff d8 ff e0), so RN never auto-rotates it.
412
+ //
413
+ // Stitcher is unaffected — it reads `keyframe-N.jpg` with EXIF IGNORED
414
+ // (IMREAD_IGNORE_ORIENTATION) so it still gets the sensor-native
415
+ // pixels its pose intrinsics expect. Display-only.
416
+ const multiThumbStyle = (0, react_1.useMemo)(() => {
417
+ const tileTransform = tileRotation(resolvedOrientation, vertical);
418
+ return tileTransform
419
+ ? [styles.multiThumb, { transform: tileTransform }]
420
+ : styles.multiThumb;
421
+ }, [resolvedOrientation, vertical]);
365
422
  return (react_1.default.createElement(react_native_1.View, { pointerEvents: "none", style: [styles.bandBase, layout.band] }, hasMultiThumb ? (
366
423
  // Multi-thumb path: one image per accepted keyframe, scrolling
367
424
  // horizontally (in JS-coords) within the band. Content
@@ -44,7 +44,17 @@ function PanoramaConfirmModal({ visible, panoramaUri, width, height, onSave, onR
44
44
  // correctly inside a flexible container without us having to
45
45
  // measure the modal's available area on every layout change.
46
46
  const aspectRatio = width > 0 && height > 0 ? width / height : 16 / 9;
47
- return (react_1.default.createElement(react_native_1.Modal, { visible: visible, animationType: "fade", transparent: true, statusBarTranslucent: true, onRequestClose: onDiscard },
47
+ return (react_1.default.createElement(react_native_1.Modal, { visible: visible, animationType: "fade", transparent: true, statusBarTranslucent: true,
48
+ // v0.13.1 — RN's iOS <Modal> defaults to portrait-only. Declare
49
+ // all four so the confirm modal stays aligned with the interface
50
+ // under a non-locked host. Mirrors OrientationDriftModal +
51
+ // PanoramaSettingsModal (v0.12) and CapturePreview (v0.13.1).
52
+ supportedOrientations: [
53
+ 'portrait',
54
+ 'portrait-upside-down',
55
+ 'landscape-left',
56
+ 'landscape-right',
57
+ ], onRequestClose: onDiscard },
48
58
  react_1.default.createElement(react_native_1.View, { style: styles.backdrop },
49
59
  react_1.default.createElement(react_native_1.Text, { style: styles.title, accessibilityRole: "header" }, title),
50
60
  react_1.default.createElement(react_native_1.View, { style: styles.imageWrapper },
@@ -0,0 +1,93 @@
1
+ /**
2
+ * selectCaptureDevice — capability-aware back-camera selection.
3
+ *
4
+ * Replaces the single-physical-device request that caused two
5
+ * user-visible bugs (see docs/plans/2026-06-01-v0.13.2-multilens-
6
+ * device-selection.md):
7
+ *
8
+ * 1. 0.5× silently showed the wide-angle FOV on phones where the
9
+ * ultra-wide is only exposed inside a multi-cam logical device —
10
+ * vision-camera's single-lens filter mis-scored and fell back to
11
+ * a plain wide-angle device.
12
+ * 2. flash threw `flash-not-available` on 0.5× because the standalone
13
+ * ultra-wide device has no torch unit.
14
+ *
15
+ * Both stem from mounting ONE standalone physical device per lens. The
16
+ * fix: prefer a MULTI-CAM device that carries the ultra-wide (so a
17
+ * single mounted device spans both FOVs via zoom AND carries the torch
18
+ * through its wide-angle member). Fall back to standalone devices for
19
+ * phones — common on Android — where the ultra-wide has no multi-cam
20
+ * grouping, so we don't regress those.
21
+ *
22
+ * Pure + synchronous: takes a plain device list (the structural subset
23
+ * of vision-camera's `CameraDevice` we need) and returns the choice.
24
+ * No React, no vision-camera hooks — unit-tested directly.
25
+ */
26
+ export type LensType = 'ultra-wide-angle-camera' | 'wide-angle-camera' | 'telephoto-camera';
27
+ /**
28
+ * The structural subset of vision-camera's `CameraDevice` this selector
29
+ * reads. Declared locally (not imported) so tests can build synthetic
30
+ * devices without the full vision-camera type, and so the SDK doesn't
31
+ * couple its selection logic to vision-camera's evolving shape.
32
+ */
33
+ export interface DeviceLike {
34
+ id: string;
35
+ position: 'front' | 'back' | 'external';
36
+ physicalDevices: LensType[];
37
+ isMultiCam: boolean;
38
+ hasTorch: boolean;
39
+ minZoom: number;
40
+ neutralZoom: number;
41
+ maxZoom: number;
42
+ }
43
+ export type CaptureDeviceMode =
44
+ /** One multi-cam device spans wide + ultra-wide; switch lenses via zoom. */
45
+ 'multicam'
46
+ /** Separate standalone wide + ultra-wide devices; switch by remounting. */
47
+ | 'standalone-uw'
48
+ /** No ultra-wide anywhere; wide-angle only (no 0.5× chip). */
49
+ | 'wide-only';
50
+ export interface CaptureDeviceSelection<D extends DeviceLike = DeviceLike> {
51
+ /** The device to mount for the `1×` lens (and for `multicam`, all lenses). */
52
+ device: D | null;
53
+ /**
54
+ * The device to mount when the user picks `0.5×` in `standalone-uw`
55
+ * mode (a separate physical ultra-wide). Null in `multicam` (same
56
+ * device, zoom instead) and `wide-only` (no ultra-wide).
57
+ */
58
+ ultraWideDevice: D | null;
59
+ mode: CaptureDeviceMode;
60
+ /** Whether a 0.5× chooser should be offered at all. */
61
+ has0_5x: boolean;
62
+ /** Whether the `1×`/primary mounted device can flash (drives flash UI). */
63
+ hasTorch: boolean;
64
+ }
65
+ /**
66
+ * Choose the back-camera device(s) for capture.
67
+ *
68
+ * Priority:
69
+ * 1. multicam — a multi-cam device containing BOTH wide + ultra-wide
70
+ * (best: one device, zoom-switch, torch via the wide member).
71
+ * 2. standalone-uw — a standalone wide AND a standalone ultra-wide
72
+ * exist as separate devices (device-swap on lens change; flash
73
+ * hidden on the torchless ultra-wide).
74
+ * 3. wide-only — no ultra-wide reachable; wide-angle only.
75
+ *
76
+ * @param devices All enumerated camera devices (any position).
77
+ */
78
+ export declare function selectCaptureDevice<D extends DeviceLike>(devices: readonly D[]): CaptureDeviceSelection<D>;
79
+ /**
80
+ * Map a UI lens label to a vision-camera `zoom` value for the
81
+ * `multicam` mode (where lens switching is zoom, not device swap).
82
+ *
83
+ * - `1×` → the device's `neutralZoom` (wide-angle baseline; vision-
84
+ * camera docs: "where the camera is in wide-angle mode and hasn't
85
+ * switched to ultra-wide or telephoto yet").
86
+ * - `0.5×` → `minZoom` (the ultra-wide end of the zoom range).
87
+ *
88
+ * Returns `neutralZoom` for any non-0.5× label as a safe default.
89
+ * Only meaningful in `multicam` mode; the standalone path swaps devices
90
+ * and ignores this.
91
+ */
92
+ export declare function zoomForLens(device: Pick<DeviceLike, 'minZoom' | 'neutralZoom'>, lens: '1x' | '0.5x'): number;
93
+ //# sourceMappingURL=selectCaptureDevice.d.ts.map
@@ -0,0 +1,131 @@
1
+ "use strict";
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * selectCaptureDevice — capability-aware back-camera selection.
5
+ *
6
+ * Replaces the single-physical-device request that caused two
7
+ * user-visible bugs (see docs/plans/2026-06-01-v0.13.2-multilens-
8
+ * device-selection.md):
9
+ *
10
+ * 1. 0.5× silently showed the wide-angle FOV on phones where the
11
+ * ultra-wide is only exposed inside a multi-cam logical device —
12
+ * vision-camera's single-lens filter mis-scored and fell back to
13
+ * a plain wide-angle device.
14
+ * 2. flash threw `flash-not-available` on 0.5× because the standalone
15
+ * ultra-wide device has no torch unit.
16
+ *
17
+ * Both stem from mounting ONE standalone physical device per lens. The
18
+ * fix: prefer a MULTI-CAM device that carries the ultra-wide (so a
19
+ * single mounted device spans both FOVs via zoom AND carries the torch
20
+ * through its wide-angle member). Fall back to standalone devices for
21
+ * phones — common on Android — where the ultra-wide has no multi-cam
22
+ * grouping, so we don't regress those.
23
+ *
24
+ * Pure + synchronous: takes a plain device list (the structural subset
25
+ * of vision-camera's `CameraDevice` we need) and returns the choice.
26
+ * No React, no vision-camera hooks — unit-tested directly.
27
+ */
28
+ Object.defineProperty(exports, "__esModule", { value: true });
29
+ exports.selectCaptureDevice = selectCaptureDevice;
30
+ exports.zoomForLens = zoomForLens;
31
+ const hasLens = (d, lens) => d.physicalDevices.includes(lens);
32
+ /**
33
+ * Choose the back-camera device(s) for capture.
34
+ *
35
+ * Priority:
36
+ * 1. multicam — a multi-cam device containing BOTH wide + ultra-wide
37
+ * (best: one device, zoom-switch, torch via the wide member).
38
+ * 2. standalone-uw — a standalone wide AND a standalone ultra-wide
39
+ * exist as separate devices (device-swap on lens change; flash
40
+ * hidden on the torchless ultra-wide).
41
+ * 3. wide-only — no ultra-wide reachable; wide-angle only.
42
+ *
43
+ * @param devices All enumerated camera devices (any position).
44
+ */
45
+ function selectCaptureDevice(devices) {
46
+ const back = devices.filter((d) => d.position === 'back');
47
+ if (back.length === 0) {
48
+ return {
49
+ device: null,
50
+ ultraWideDevice: null,
51
+ mode: 'wide-only',
52
+ has0_5x: false,
53
+ hasTorch: false,
54
+ };
55
+ }
56
+ // ── 1. Prefer a multi-cam device that carries BOTH wide + ultra-wide.
57
+ // Among candidates, prefer the one that ALSO has a torch (so flash
58
+ // works on every lens), then the one spanning the widest zoom range
59
+ // (more lenses → more reach), as a stable tiebreak.
60
+ const multicamCandidates = back.filter((d) => d.isMultiCam &&
61
+ hasLens(d, 'wide-angle-camera') &&
62
+ hasLens(d, 'ultra-wide-angle-camera'));
63
+ if (multicamCandidates.length > 0) {
64
+ const device = multicamCandidates.reduce((best, d) => {
65
+ // torch-bearing wins; then wider zoom span; then more lenses.
66
+ if (d.hasTorch !== best.hasTorch)
67
+ return d.hasTorch ? d : best;
68
+ const span = d.maxZoom - d.minZoom;
69
+ const bestSpan = best.maxZoom - best.minZoom;
70
+ if (span !== bestSpan)
71
+ return span > bestSpan ? d : best;
72
+ return d.physicalDevices.length > best.physicalDevices.length ? d : best;
73
+ });
74
+ return {
75
+ device,
76
+ ultraWideDevice: null,
77
+ mode: 'multicam',
78
+ has0_5x: true,
79
+ hasTorch: device.hasTorch,
80
+ };
81
+ }
82
+ // ── 2. Standalone ultra-wide + standalone wide as separate devices.
83
+ // CRITICAL: this fallback is what keeps phones (esp. Android) where
84
+ // the ultra-wide has NO multi-cam grouping working — without it,
85
+ // restricting to multicam would REINTRODUCE the "0.5× shows wide" bug
86
+ // for that device population.
87
+ //
88
+ // Prefer a torch-bearing wide-angle device as the `1×`/primary mount.
89
+ const wideDevices = back.filter((d) => hasLens(d, 'wide-angle-camera'));
90
+ const ultraWide = back.find((d) => !d.isMultiCam && hasLens(d, 'ultra-wide-angle-camera')) ??
91
+ back.find((d) => hasLens(d, 'ultra-wide-angle-camera')) ??
92
+ null;
93
+ if (wideDevices.length > 0 && ultraWide != null) {
94
+ // Prefer the simplest wide device (fewest extra lenses) with a torch
95
+ // as the 1× mount, so 1× flash works. Falls back to any wide device.
96
+ const primary = wideDevices.find((d) => d.hasTorch) ?? wideDevices[0];
97
+ return {
98
+ device: primary,
99
+ ultraWideDevice: ultraWide,
100
+ mode: 'standalone-uw',
101
+ has0_5x: true,
102
+ hasTorch: primary.hasTorch,
103
+ };
104
+ }
105
+ // ── 3. Wide-angle only (no ultra-wide reachable on this device).
106
+ const wideOnly = wideDevices.find((d) => d.hasTorch) ?? wideDevices[0] ?? back[0];
107
+ return {
108
+ device: wideOnly,
109
+ ultraWideDevice: null,
110
+ mode: 'wide-only',
111
+ has0_5x: false,
112
+ hasTorch: wideOnly.hasTorch,
113
+ };
114
+ }
115
+ /**
116
+ * Map a UI lens label to a vision-camera `zoom` value for the
117
+ * `multicam` mode (where lens switching is zoom, not device swap).
118
+ *
119
+ * - `1×` → the device's `neutralZoom` (wide-angle baseline; vision-
120
+ * camera docs: "where the camera is in wide-angle mode and hasn't
121
+ * switched to ultra-wide or telephoto yet").
122
+ * - `0.5×` → `minZoom` (the ultra-wide end of the zoom range).
123
+ *
124
+ * Returns `neutralZoom` for any non-0.5× label as a safe default.
125
+ * Only meaningful in `multicam` mode; the standalone path swaps devices
126
+ * and ignores this.
127
+ */
128
+ function zoomForLens(device, lens) {
129
+ return lens === '0.5x' ? device.minZoom : device.neutralZoom;
130
+ }
131
+ //# sourceMappingURL=selectCaptureDevice.js.map
@@ -24,6 +24,7 @@
24
24
  * still use the SDK's quality + stitching modules.
25
25
  */
26
26
  import { Camera, useCameraDevice, type PhysicalCameraDeviceType, type TakePhotoOptions } from 'react-native-vision-camera';
27
+ import { type CaptureDeviceMode } from './selectCaptureDevice';
27
28
  import type { CaptureResult, QualityThresholds } from '../types';
28
29
  /**
29
30
  * Hook input. Everything optional; sensible defaults are applied
@@ -66,8 +67,22 @@ export interface UseCaptureOptions {
66
67
  * behaves as if `preferredPhysicalDevice` was undefined). The
67
68
  * returned `availablePhysicalDevices` exposes what the device
68
69
  * actually offers so the host can render an appropriate switcher.
70
+ *
71
+ * v0.13.2 — superseded by `lens` for `<Camera>`'s own use (see
72
+ * `selectCaptureDevice`). Still honoured for direct Layer-2 hosts.
69
73
  */
70
74
  preferredPhysicalDevice?: PhysicalCameraDeviceType;
75
+ /**
76
+ * v0.13.2 — the active UI lens (`1×` / `0.5×`). When supplied, the
77
+ * hook uses capability-aware selection (`selectCaptureDevice`): it
78
+ * prefers a multi-cam device spanning both FOVs (lens switched via
79
+ * `zoom`, torch available on every lens), and falls back to a
80
+ * standalone ultra-wide device-swap only where no such multi-cam
81
+ * device exists. Fixes the "0.5× shows wide-angle on some phones"
82
+ * and "flash unavailable on 0.5×" bugs. When omitted, the legacy
83
+ * `preferredPhysicalDevice` path is used (backwards-compatible).
84
+ */
85
+ lens?: '1x' | '0.5x';
71
86
  }
72
87
  /**
73
88
  * Per-call options for `takePhoto`. Separate from `UseCaptureOptions`
@@ -136,6 +151,31 @@ export interface UseCaptureReturn {
136
151
  * load). Always populated by the time the camera is mountable.
137
152
  */
138
153
  availablePhysicalDevices: PhysicalCameraDeviceType[];
154
+ /**
155
+ * v0.13.2 — how lenses are switched for the mounted device:
156
+ * 'multicam' — one device spans both FOVs; switch via `deviceZoom`.
157
+ * 'standalone-uw' — separate ultra-wide device; switch by remounting.
158
+ * 'wide-only' — no ultra-wide; no 0.5× chooser.
159
+ */
160
+ captureMode: CaptureDeviceMode;
161
+ /**
162
+ * v0.13.2 — whether the device can offer a 0.5× ultra-wide lens AT ALL
163
+ * (real capability, replacing the old hardcoded assumption). Drives
164
+ * whether `<Camera>` renders the lens chooser.
165
+ */
166
+ has0_5x: boolean;
167
+ /**
168
+ * v0.13.2 — whether the currently-MOUNTED device has a torch. Drives
169
+ * the flash control's availability (the standalone ultra-wide has none).
170
+ */
171
+ deviceHasTorch: boolean;
172
+ /**
173
+ * v0.13.2 — the `zoom` value to apply for the active lens in
174
+ * `multicam` mode (0.5× → ultra-wide end, 1× → wide baseline).
175
+ * `undefined` in standalone/wide-only modes (lens = device identity,
176
+ * no zoom needed). Pass to `<CameraView zoom>`.
177
+ */
178
+ deviceZoom: number | undefined;
139
179
  }
140
180
  export declare function useCapture(options?: UseCaptureOptions): UseCaptureReturn;
141
181
  //# sourceMappingURL=useCapture.d.ts.map