react-native-image-stitcher 0.13.0 → 0.14.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/README.md +33 -17
  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
@@ -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
@@ -29,6 +29,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
29
29
  exports.useCapture = useCapture;
30
30
  const react_1 = require("react");
31
31
  const react_native_vision_camera_1 = require("react-native-vision-camera");
32
+ const selectCaptureDevice_1 = require("./selectCaptureDevice");
32
33
  const runQualityCheck_1 = require("../quality/runQualityCheck");
33
34
  const normaliseOrientation_1 = require("../quality/normaliseOrientation");
34
35
  const paths_1 = require("../utils/paths");
@@ -61,28 +62,61 @@ function makeCaptureResult(photo, qualityReport) {
61
62
  };
62
63
  }
63
64
  function useCapture(options = {}) {
64
- const { cameraPosition = 'back', enableQualityChecks = false, qualityThresholds, takePhotoOptions, preferredPhysicalDevice, } = options;
65
+ const { cameraPosition = 'back', enableQualityChecks = false, qualityThresholds, takePhotoOptions, preferredPhysicalDevice, lens, } = options;
65
66
  const cameraRef = (0, react_1.useRef)(null);
66
- // 2026-05-14 physical-lens-aware device picker.
67
+ const allDevices = (0, react_native_vision_camera_1.useCameraDevices)();
68
+ // v0.13.2 — capability-aware selection (`lens` supplied) vs legacy
69
+ // per-lens physical-device swap (`preferredPhysicalDevice`).
67
70
  //
68
- // When `preferredPhysicalDevice` is supplied, ask vision-camera
69
- // for a device that exposes that specific physical lens (e.g.,
70
- // 'ultra-wide-angle-camera'). Falls back to the position-default
71
- // when the device doesn't have that lens. When undefined, behaves
72
- // identically to the pre-2026-05-14 useCameraDevice(position) call.
73
- const deviceWithPreferred = (0, react_native_vision_camera_1.useCameraDevice)(cameraPosition, {
71
+ // Capability-aware: `selectCaptureDevice` prefers a multi-cam device
72
+ // that spans wide + ultra-wide (so 0.5× is reached via `zoom` and the
73
+ // torch works on every lens), falling back to a standalone ultra-wide
74
+ // device-swap only where the platform has no such multi-cam grouping.
75
+ // This fixes (a) 0.5× silently showing the wide-angle FOV on phones
76
+ // where the ultra-wide is only inside a multi-cam device, and (b)
77
+ // flash being unavailable on the torchless standalone ultra-wide.
78
+ const selection = (0, react_1.useMemo)(() => (0, selectCaptureDevice_1.selectCaptureDevice)(allDevices), [allDevices]);
79
+ // Legacy path (no `lens`): preserve the pre-v0.13.2 per-physical-lens
80
+ // request so direct Layer-2 hosts that pass `preferredPhysicalDevice`
81
+ // are unaffected.
82
+ const legacyDevice = (0, react_native_vision_camera_1.useCameraDevice)(cameraPosition, {
74
83
  physicalDevices: preferredPhysicalDevice ? [preferredPhysicalDevice] : undefined,
75
84
  });
76
- const deviceFallback = (0, react_native_vision_camera_1.useCameraDevice)(cameraPosition);
77
- const device = deviceWithPreferred ?? deviceFallback;
85
+ const legacyFallback = (0, react_native_vision_camera_1.useCameraDevice)(cameraPosition);
86
+ // The mounted device:
87
+ // - lens supplied + multicam → the single multi-cam device
88
+ // (lens switched via `zoom`, computed below).
89
+ // - lens supplied + standalone-uw→ swap to the ultra-wide device on
90
+ // 0.5×, else the wide primary (matches the legacy swap, but with
91
+ // correct device identity from `selectCaptureDevice`).
92
+ // - lens supplied + wide-only → the wide device (0.5× hidden).
93
+ // - no lens → legacy behaviour.
94
+ let device;
95
+ let activeZoom;
96
+ if (lens != null) {
97
+ if (selection.mode === 'standalone-uw' && lens === '0.5x') {
98
+ device = selection.ultraWideDevice ?? selection.device ?? legacyFallback;
99
+ }
100
+ else {
101
+ device = selection.device ?? legacyFallback;
102
+ }
103
+ activeZoom =
104
+ selection.mode === 'multicam' && selection.device
105
+ ? (0, selectCaptureDevice_1.zoomForLens)(selection.device, lens)
106
+ : undefined;
107
+ }
108
+ else {
109
+ device = legacyDevice ?? legacyFallback;
110
+ activeZoom = undefined;
111
+ }
78
112
  // Enumerate ALL physical lens types available on the chosen
79
113
  // position so the host can decide whether to render a switcher.
80
114
  // Vision-camera's `useCameraDevices()` returns CameraDevice[]; each
81
115
  // has `physicalDevices: PhysicalCameraDeviceType[]`. We dedupe the
82
116
  // union across all devices at `position` so the host sees the full
83
117
  // set the platform exposes (some phones expose ultra-wide only via
84
- // a separate logical camera, not the main one).
85
- const allDevices = (0, react_native_vision_camera_1.useCameraDevices)();
118
+ // a separate logical camera, not the main one). `allDevices` is
119
+ // computed once above (shared with `selectCaptureDevice`).
86
120
  const availablePhysicalDevices = (0, react_1.useMemo)(() => {
87
121
  const seen = new Set();
88
122
  for (const d of allDevices) {
@@ -176,6 +210,10 @@ function useCapture(options = {}) {
176
210
  isCapturing,
177
211
  takePhoto,
178
212
  availablePhysicalDevices,
213
+ captureMode: selection.mode,
214
+ has0_5x: selection.has0_5x,
215
+ deviceHasTorch: device?.hasTorch ?? false,
216
+ deviceZoom: activeZoom,
179
217
  };
180
218
  }
181
219
  //# sourceMappingURL=useCapture.js.map