react-native-image-stitcher 0.12.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 +181 -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 +226 -0
  6. package/dist/camera/Camera.js +208 -20
  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 +546 -32
  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
@@ -0,0 +1,177 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Unit tests for `selectCaptureDevice` + `zoomForLens` — the pure
4
+ * capability-aware back-camera selection (v0.13.2).
5
+ *
6
+ * Covers the device matrix from the plan
7
+ * (docs/plans/2026-06-01-v0.13.2-multilens-device-selection.md),
8
+ * including the critical edge cases:
9
+ * - ultra-wide ONLY inside a multi-cam device (Symptom 1 fix)
10
+ * - ultra-wide ONLY as a standalone device (Android; must NOT regress)
11
+ * - ultra-wide present BOTH ways (prefer multicam)
12
+ *
13
+ * Pure — no mocks needed; we build synthetic DeviceLike lists.
14
+ */
15
+
16
+ import {
17
+ selectCaptureDevice,
18
+ zoomForLens,
19
+ type DeviceLike,
20
+ } from '../selectCaptureDevice';
21
+
22
+ // ── Synthetic device builders ───────────────────────────────────────
23
+ let idCounter = 0;
24
+ function dev(partial: Partial<DeviceLike>): DeviceLike {
25
+ idCounter += 1;
26
+ return {
27
+ id: `dev-${idCounter}`,
28
+ position: 'back',
29
+ physicalDevices: ['wide-angle-camera'],
30
+ isMultiCam: false,
31
+ hasTorch: true,
32
+ minZoom: 1,
33
+ neutralZoom: 1,
34
+ maxZoom: 10,
35
+ ...partial,
36
+ };
37
+ }
38
+
39
+ const tripleCam = (p: Partial<DeviceLike> = {}) =>
40
+ dev({
41
+ physicalDevices: ['ultra-wide-angle-camera', 'wide-angle-camera', 'telephoto-camera'],
42
+ isMultiCam: true,
43
+ hasTorch: true,
44
+ minZoom: 0.5,
45
+ neutralZoom: 1,
46
+ maxZoom: 30,
47
+ ...p,
48
+ });
49
+ const dualWide = (p: Partial<DeviceLike> = {}) =>
50
+ dev({
51
+ physicalDevices: ['ultra-wide-angle-camera', 'wide-angle-camera'],
52
+ isMultiCam: true,
53
+ hasTorch: true,
54
+ minZoom: 0.5,
55
+ neutralZoom: 1,
56
+ maxZoom: 6,
57
+ ...p,
58
+ });
59
+ const standaloneWide = (p: Partial<DeviceLike> = {}) =>
60
+ dev({ physicalDevices: ['wide-angle-camera'], isMultiCam: false, hasTorch: true, ...p });
61
+ const standaloneUltraWide = (p: Partial<DeviceLike> = {}) =>
62
+ dev({ physicalDevices: ['ultra-wide-angle-camera'], isMultiCam: false, hasTorch: false, ...p });
63
+
64
+ describe('selectCaptureDevice', () => {
65
+ it('picks the MULTICAM device when one spans wide + ultra-wide (triple cam)', () => {
66
+ const triple = tripleCam();
67
+ const sel = selectCaptureDevice([triple, standaloneWide(), standaloneUltraWide()]);
68
+ expect(sel.mode).toBe('multicam');
69
+ expect(sel.device).toBe(triple);
70
+ expect(sel.ultraWideDevice).toBeNull();
71
+ expect(sel.has0_5x).toBe(true);
72
+ expect(sel.hasTorch).toBe(true);
73
+ });
74
+
75
+ it('picks the MULTICAM device for a dual-wide grouping', () => {
76
+ const dual = dualWide();
77
+ const sel = selectCaptureDevice([dual, standaloneWide()]);
78
+ expect(sel.mode).toBe('multicam');
79
+ expect(sel.device).toBe(dual);
80
+ expect(sel.has0_5x).toBe(true);
81
+ });
82
+
83
+ it('SYMPTOM 1 FIX: ultra-wide ONLY in a multi-cam device → multicam (not wide fallback)', () => {
84
+ // The exact bug: a phone where ultra-wide is bundled in a multicam
85
+ // device and there is NO standalone ultra-wide. Old single-lens
86
+ // filter fell back to wide-angle; we must pick the multicam.
87
+ const dual = dualWide();
88
+ const wide = standaloneWide();
89
+ const sel = selectCaptureDevice([wide, dual]);
90
+ expect(sel.mode).toBe('multicam');
91
+ expect(sel.device).toBe(dual);
92
+ expect(sel.has0_5x).toBe(true);
93
+ });
94
+
95
+ it('EDGE: ultra-wide ONLY as a standalone device (Android) → standalone-uw (no regression)', () => {
96
+ // No multicam grouping at all. Must still expose 0.5× via the
97
+ // standalone ultra-wide, mounting the wide-angle as primary.
98
+ const wide = standaloneWide();
99
+ const uw = standaloneUltraWide();
100
+ const sel = selectCaptureDevice([wide, uw]);
101
+ expect(sel.mode).toBe('standalone-uw');
102
+ expect(sel.device).toBe(wide); // primary = torch-bearing wide
103
+ expect(sel.ultraWideDevice).toBe(uw);
104
+ expect(sel.has0_5x).toBe(true);
105
+ expect(sel.hasTorch).toBe(true); // the 1× mount has a torch
106
+ });
107
+
108
+ it('EDGE: ultra-wide present BOTH standalone AND in multicam → prefer multicam', () => {
109
+ const dual = dualWide();
110
+ const wide = standaloneWide();
111
+ const uw = standaloneUltraWide();
112
+ const sel = selectCaptureDevice([uw, wide, dual]);
113
+ expect(sel.mode).toBe('multicam');
114
+ expect(sel.device).toBe(dual);
115
+ });
116
+
117
+ it('wide-angle ONLY (no ultra-wide anywhere) → wide-only, no 0.5×', () => {
118
+ const wide = standaloneWide();
119
+ const sel = selectCaptureDevice([wide]);
120
+ expect(sel.mode).toBe('wide-only');
121
+ expect(sel.device).toBe(wide);
122
+ expect(sel.has0_5x).toBe(false);
123
+ expect(sel.ultraWideDevice).toBeNull();
124
+ });
125
+
126
+ it('prefers a TORCH-bearing multicam device over a torchless one', () => {
127
+ const noTorch = dualWide({ hasTorch: false });
128
+ const withTorch = tripleCam({ hasTorch: true });
129
+ const sel = selectCaptureDevice([noTorch, withTorch]);
130
+ expect(sel.mode).toBe('multicam');
131
+ expect(sel.device).toBe(withTorch);
132
+ expect(sel.hasTorch).toBe(true);
133
+ });
134
+
135
+ it('ignores front-facing devices', () => {
136
+ const front = dev({ position: 'front', physicalDevices: ['ultra-wide-angle-camera', 'wide-angle-camera'], isMultiCam: true });
137
+ const backWide = standaloneWide();
138
+ const sel = selectCaptureDevice([front, backWide]);
139
+ expect(sel.mode).toBe('wide-only'); // front multicam doesn't count
140
+ expect(sel.device).toBe(backWide);
141
+ });
142
+
143
+ it('empty device list → null device, wide-only, no 0.5×', () => {
144
+ const sel = selectCaptureDevice([]);
145
+ expect(sel.device).toBeNull();
146
+ expect(sel.mode).toBe('wide-only');
147
+ expect(sel.has0_5x).toBe(false);
148
+ expect(sel.hasTorch).toBe(false);
149
+ });
150
+
151
+ it('standalone-uw: primary prefers a torch-bearing wide when multiple wides exist', () => {
152
+ const wideNoTorch = standaloneWide({ hasTorch: false });
153
+ const wideTorch = standaloneWide({ hasTorch: true });
154
+ const uw = standaloneUltraWide();
155
+ const sel = selectCaptureDevice([wideNoTorch, uw, wideTorch]);
156
+ expect(sel.mode).toBe('standalone-uw');
157
+ expect(sel.device).toBe(wideTorch);
158
+ expect(sel.hasTorch).toBe(true);
159
+ });
160
+ });
161
+
162
+ describe('zoomForLens (multicam lens→zoom mapping)', () => {
163
+ const d = { minZoom: 0.5, neutralZoom: 1 };
164
+
165
+ it('maps 0.5× to the device minZoom (ultra-wide end)', () => {
166
+ expect(zoomForLens(d, '0.5x')).toBe(0.5);
167
+ });
168
+
169
+ it('maps 1× to the device neutralZoom (wide-angle baseline)', () => {
170
+ expect(zoomForLens(d, '1x')).toBe(1);
171
+ });
172
+
173
+ it('handles a device whose neutralZoom differs from 1', () => {
174
+ expect(zoomForLens({ minZoom: 0.6, neutralZoom: 2 }, '1x')).toBe(2);
175
+ expect(zoomForLens({ minZoom: 0.6, neutralZoom: 2 }, '0.5x')).toBe(0.6);
176
+ });
177
+ });
@@ -0,0 +1,89 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Unit tests for `contentRotationDeg` — the pure rotation computation
4
+ * behind `useContentRotation`, which keeps control content (AR toggle,
5
+ * lens/zoom pill, flash, thumbnails) upright relative to gravity
6
+ * regardless of host portrait-lock state.
7
+ *
8
+ * Covers the full truth table from the hook's docstring plus the
9
+ * mid-rotation transients (jsLandscape=true with a non-landscape device
10
+ * reading, which can briefly happen while the OS catches up).
11
+ *
12
+ * Pure-TS test per jest.config.js. `useContentRotation` transitively
13
+ * imports `useDeviceOrientation` → `react-native-sensors` (an ES module
14
+ * the no-RN-preset jest infra can't parse), so stub it before importing
15
+ * the SUT. We only call the pure `contentRotationDeg` export.
16
+ */
17
+
18
+ jest.mock('react-native-sensors', () => ({
19
+ accelerometer: { subscribe: jest.fn(() => ({ unsubscribe: jest.fn() })) },
20
+ gyroscope: { subscribe: jest.fn(() => ({ unsubscribe: jest.fn() })) },
21
+ setUpdateIntervalForType: jest.fn(),
22
+ SensorTypes: { accelerometer: 'accelerometer', gyroscope: 'gyroscope' },
23
+ }));
24
+
25
+ import { contentRotationDeg } from '../useContentRotation';
26
+
27
+ describe('contentRotationDeg', () => {
28
+ // Locked-portrait host: jsLandscape is ALWAYS false (window dims stay
29
+ // portrait regardless of device tilt). The OS doesn't rotate the
30
+ // framebuffer, so content rotation must match device-physical for
31
+ // labels to read upright. THIS is the case task #5b targets.
32
+
33
+ it('locked-portrait + device-portrait → 0° (no-op)', () => {
34
+ expect(contentRotationDeg(false, 'portrait')).toBe(0);
35
+ });
36
+
37
+ it('locked-portrait + device-landscape-left → 90° (CW)', () => {
38
+ expect(contentRotationDeg(false, 'landscape-left')).toBe(90);
39
+ });
40
+
41
+ it('locked-portrait + device-landscape-right → -90° (CCW)', () => {
42
+ expect(contentRotationDeg(false, 'landscape-right')).toBe(-90);
43
+ });
44
+
45
+ it('locked-portrait + device-upside-down → 180°', () => {
46
+ expect(contentRotationDeg(false, 'portrait-upside-down')).toBe(180);
47
+ });
48
+
49
+ // Non-locked host + device-landscape: OS rotated the framebuffer for
50
+ // us; we must NOT double-rotate. Net rotation must be 0.
51
+
52
+ it('non-locked + device-landscape-left (jsLandscape=true) → 0°', () => {
53
+ expect(contentRotationDeg(true, 'landscape-left')).toBe(0);
54
+ });
55
+
56
+ it('non-locked + device-landscape-right (jsLandscape=true) → 0°', () => {
57
+ expect(contentRotationDeg(true, 'landscape-right')).toBe(0);
58
+ });
59
+
60
+ it('non-locked + device-portrait (jsLandscape=false) → 0°', () => {
61
+ expect(contentRotationDeg(false, 'portrait')).toBe(0);
62
+ });
63
+
64
+ // Mid-rotation transients: jsLandscape=true with a non-landscape
65
+ // device reading. Falls through to 0 framebuffer rotation and
66
+ // applies device rotation directly; settles once the transient clears.
67
+
68
+ it('jsLandscape=true mid-rotation with device-portrait → 0°', () => {
69
+ expect(contentRotationDeg(true, 'portrait')).toBe(0);
70
+ });
71
+
72
+ it('jsLandscape=true mid-rotation with device-upside-down → 180°', () => {
73
+ expect(contentRotationDeg(true, 'portrait-upside-down')).toBe(180);
74
+ });
75
+
76
+ it('all returned values are in {0, 90, -90, 180} (no off-by-360°)', () => {
77
+ const orientations = [
78
+ 'portrait',
79
+ 'portrait-upside-down',
80
+ 'landscape-left',
81
+ 'landscape-right',
82
+ ] as const;
83
+ for (const o of orientations) {
84
+ for (const jsl of [true, false]) {
85
+ expect([0, 90, -90, 180]).toContain(contentRotationDeg(jsl, o));
86
+ }
87
+ }
88
+ });
89
+ });
@@ -0,0 +1,187 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * selectCaptureDevice — capability-aware back-camera selection.
4
+ *
5
+ * Replaces the single-physical-device request that caused two
6
+ * user-visible bugs (see docs/plans/2026-06-01-v0.13.2-multilens-
7
+ * device-selection.md):
8
+ *
9
+ * 1. 0.5× silently showed the wide-angle FOV on phones where the
10
+ * ultra-wide is only exposed inside a multi-cam logical device —
11
+ * vision-camera's single-lens filter mis-scored and fell back to
12
+ * a plain wide-angle device.
13
+ * 2. flash threw `flash-not-available` on 0.5× because the standalone
14
+ * ultra-wide device has no torch unit.
15
+ *
16
+ * Both stem from mounting ONE standalone physical device per lens. The
17
+ * fix: prefer a MULTI-CAM device that carries the ultra-wide (so a
18
+ * single mounted device spans both FOVs via zoom AND carries the torch
19
+ * through its wide-angle member). Fall back to standalone devices for
20
+ * phones — common on Android — where the ultra-wide has no multi-cam
21
+ * grouping, so we don't regress those.
22
+ *
23
+ * Pure + synchronous: takes a plain device list (the structural subset
24
+ * of vision-camera's `CameraDevice` we need) and returns the choice.
25
+ * No React, no vision-camera hooks — unit-tested directly.
26
+ */
27
+
28
+ export type LensType = 'ultra-wide-angle-camera' | 'wide-angle-camera' | 'telephoto-camera';
29
+
30
+ /**
31
+ * The structural subset of vision-camera's `CameraDevice` this selector
32
+ * reads. Declared locally (not imported) so tests can build synthetic
33
+ * devices without the full vision-camera type, and so the SDK doesn't
34
+ * couple its selection logic to vision-camera's evolving shape.
35
+ */
36
+ export interface DeviceLike {
37
+ id: string;
38
+ position: 'front' | 'back' | 'external';
39
+ physicalDevices: LensType[];
40
+ isMultiCam: boolean;
41
+ hasTorch: boolean;
42
+ minZoom: number;
43
+ neutralZoom: number;
44
+ maxZoom: number;
45
+ }
46
+
47
+ export type CaptureDeviceMode =
48
+ /** One multi-cam device spans wide + ultra-wide; switch lenses via zoom. */
49
+ | 'multicam'
50
+ /** Separate standalone wide + ultra-wide devices; switch by remounting. */
51
+ | 'standalone-uw'
52
+ /** No ultra-wide anywhere; wide-angle only (no 0.5× chip). */
53
+ | 'wide-only';
54
+
55
+ export interface CaptureDeviceSelection<D extends DeviceLike = DeviceLike> {
56
+ /** The device to mount for the `1×` lens (and for `multicam`, all lenses). */
57
+ device: D | null;
58
+ /**
59
+ * The device to mount when the user picks `0.5×` in `standalone-uw`
60
+ * mode (a separate physical ultra-wide). Null in `multicam` (same
61
+ * device, zoom instead) and `wide-only` (no ultra-wide).
62
+ */
63
+ ultraWideDevice: D | null;
64
+ mode: CaptureDeviceMode;
65
+ /** Whether a 0.5× chooser should be offered at all. */
66
+ has0_5x: boolean;
67
+ /** Whether the `1×`/primary mounted device can flash (drives flash UI). */
68
+ hasTorch: boolean;
69
+ }
70
+
71
+ const hasLens = (d: DeviceLike, lens: LensType) =>
72
+ d.physicalDevices.includes(lens);
73
+
74
+ /**
75
+ * Choose the back-camera device(s) for capture.
76
+ *
77
+ * Priority:
78
+ * 1. multicam — a multi-cam device containing BOTH wide + ultra-wide
79
+ * (best: one device, zoom-switch, torch via the wide member).
80
+ * 2. standalone-uw — a standalone wide AND a standalone ultra-wide
81
+ * exist as separate devices (device-swap on lens change; flash
82
+ * hidden on the torchless ultra-wide).
83
+ * 3. wide-only — no ultra-wide reachable; wide-angle only.
84
+ *
85
+ * @param devices All enumerated camera devices (any position).
86
+ */
87
+ export function selectCaptureDevice<D extends DeviceLike>(
88
+ devices: readonly D[],
89
+ ): CaptureDeviceSelection<D> {
90
+ const back = devices.filter((d) => d.position === 'back');
91
+
92
+ if (back.length === 0) {
93
+ return {
94
+ device: null,
95
+ ultraWideDevice: null,
96
+ mode: 'wide-only',
97
+ has0_5x: false,
98
+ hasTorch: false,
99
+ };
100
+ }
101
+
102
+ // ── 1. Prefer a multi-cam device that carries BOTH wide + ultra-wide.
103
+ // Among candidates, prefer the one that ALSO has a torch (so flash
104
+ // works on every lens), then the one spanning the widest zoom range
105
+ // (more lenses → more reach), as a stable tiebreak.
106
+ const multicamCandidates = back.filter(
107
+ (d) =>
108
+ d.isMultiCam &&
109
+ hasLens(d, 'wide-angle-camera') &&
110
+ hasLens(d, 'ultra-wide-angle-camera'),
111
+ );
112
+ if (multicamCandidates.length > 0) {
113
+ const device = multicamCandidates.reduce((best, d) => {
114
+ // torch-bearing wins; then wider zoom span; then more lenses.
115
+ if (d.hasTorch !== best.hasTorch) return d.hasTorch ? d : best;
116
+ const span = d.maxZoom - d.minZoom;
117
+ const bestSpan = best.maxZoom - best.minZoom;
118
+ if (span !== bestSpan) return span > bestSpan ? d : best;
119
+ return d.physicalDevices.length > best.physicalDevices.length ? d : best;
120
+ });
121
+ return {
122
+ device,
123
+ ultraWideDevice: null,
124
+ mode: 'multicam',
125
+ has0_5x: true,
126
+ hasTorch: device.hasTorch,
127
+ };
128
+ }
129
+
130
+ // ── 2. Standalone ultra-wide + standalone wide as separate devices.
131
+ // CRITICAL: this fallback is what keeps phones (esp. Android) where
132
+ // the ultra-wide has NO multi-cam grouping working — without it,
133
+ // restricting to multicam would REINTRODUCE the "0.5× shows wide" bug
134
+ // for that device population.
135
+ //
136
+ // Prefer a torch-bearing wide-angle device as the `1×`/primary mount.
137
+ const wideDevices = back.filter((d) => hasLens(d, 'wide-angle-camera'));
138
+ const ultraWide =
139
+ back.find((d) => !d.isMultiCam && hasLens(d, 'ultra-wide-angle-camera')) ??
140
+ back.find((d) => hasLens(d, 'ultra-wide-angle-camera')) ??
141
+ null;
142
+
143
+ if (wideDevices.length > 0 && ultraWide != null) {
144
+ // Prefer the simplest wide device (fewest extra lenses) with a torch
145
+ // as the 1× mount, so 1× flash works. Falls back to any wide device.
146
+ const primary =
147
+ wideDevices.find((d) => d.hasTorch) ?? wideDevices[0];
148
+ return {
149
+ device: primary,
150
+ ultraWideDevice: ultraWide,
151
+ mode: 'standalone-uw',
152
+ has0_5x: true,
153
+ hasTorch: primary.hasTorch,
154
+ };
155
+ }
156
+
157
+ // ── 3. Wide-angle only (no ultra-wide reachable on this device).
158
+ const wideOnly =
159
+ wideDevices.find((d) => d.hasTorch) ?? wideDevices[0] ?? back[0];
160
+ return {
161
+ device: wideOnly,
162
+ ultraWideDevice: null,
163
+ mode: 'wide-only',
164
+ has0_5x: false,
165
+ hasTorch: wideOnly.hasTorch,
166
+ };
167
+ }
168
+
169
+ /**
170
+ * Map a UI lens label to a vision-camera `zoom` value for the
171
+ * `multicam` mode (where lens switching is zoom, not device swap).
172
+ *
173
+ * - `1×` → the device's `neutralZoom` (wide-angle baseline; vision-
174
+ * camera docs: "where the camera is in wide-angle mode and hasn't
175
+ * switched to ultra-wide or telephoto yet").
176
+ * - `0.5×` → `minZoom` (the ultra-wide end of the zoom range).
177
+ *
178
+ * Returns `neutralZoom` for any non-0.5× label as a safe default.
179
+ * Only meaningful in `multicam` mode; the standalone path swaps devices
180
+ * and ignores this.
181
+ */
182
+ export function zoomForLens(
183
+ device: Pick<DeviceLike, 'minZoom' | 'neutralZoom'>,
184
+ lens: '1x' | '0.5x',
185
+ ): number {
186
+ return lens === '0.5x' ? device.minZoom : device.neutralZoom;
187
+ }
@@ -36,6 +36,12 @@ import {
36
36
  type TakePhotoOptions,
37
37
  } from 'react-native-vision-camera';
38
38
 
39
+ import {
40
+ selectCaptureDevice,
41
+ zoomForLens,
42
+ type CaptureDeviceMode,
43
+ type DeviceLike,
44
+ } from './selectCaptureDevice';
39
45
  import { runQualityCheck } from '../quality/runQualityCheck';
40
46
  import { normaliseOrientation } from '../quality/normaliseOrientation';
41
47
  import { toBareFilePath } from '../utils/paths';
@@ -92,8 +98,22 @@ export interface UseCaptureOptions {
92
98
  * behaves as if `preferredPhysicalDevice` was undefined). The
93
99
  * returned `availablePhysicalDevices` exposes what the device
94
100
  * actually offers so the host can render an appropriate switcher.
101
+ *
102
+ * v0.13.2 — superseded by `lens` for `<Camera>`'s own use (see
103
+ * `selectCaptureDevice`). Still honoured for direct Layer-2 hosts.
95
104
  */
96
105
  preferredPhysicalDevice?: PhysicalCameraDeviceType;
106
+ /**
107
+ * v0.13.2 — the active UI lens (`1×` / `0.5×`). When supplied, the
108
+ * hook uses capability-aware selection (`selectCaptureDevice`): it
109
+ * prefers a multi-cam device spanning both FOVs (lens switched via
110
+ * `zoom`, torch available on every lens), and falls back to a
111
+ * standalone ultra-wide device-swap only where no such multi-cam
112
+ * device exists. Fixes the "0.5× shows wide-angle on some phones"
113
+ * and "flash unavailable on 0.5×" bugs. When omitted, the legacy
114
+ * `preferredPhysicalDevice` path is used (backwards-compatible).
115
+ */
116
+ lens?: '1x' | '0.5x';
97
117
  }
98
118
 
99
119
 
@@ -166,6 +186,31 @@ export interface UseCaptureReturn {
166
186
  * load). Always populated by the time the camera is mountable.
167
187
  */
168
188
  availablePhysicalDevices: PhysicalCameraDeviceType[];
189
+ /**
190
+ * v0.13.2 — how lenses are switched for the mounted device:
191
+ * 'multicam' — one device spans both FOVs; switch via `deviceZoom`.
192
+ * 'standalone-uw' — separate ultra-wide device; switch by remounting.
193
+ * 'wide-only' — no ultra-wide; no 0.5× chooser.
194
+ */
195
+ captureMode: CaptureDeviceMode;
196
+ /**
197
+ * v0.13.2 — whether the device can offer a 0.5× ultra-wide lens AT ALL
198
+ * (real capability, replacing the old hardcoded assumption). Drives
199
+ * whether `<Camera>` renders the lens chooser.
200
+ */
201
+ has0_5x: boolean;
202
+ /**
203
+ * v0.13.2 — whether the currently-MOUNTED device has a torch. Drives
204
+ * the flash control's availability (the standalone ultra-wide has none).
205
+ */
206
+ deviceHasTorch: boolean;
207
+ /**
208
+ * v0.13.2 — the `zoom` value to apply for the active lens in
209
+ * `multicam` mode (0.5× → ultra-wide end, 1× → wide baseline).
210
+ * `undefined` in standalone/wide-only modes (lens = device identity,
211
+ * no zoom needed). Pass to `<CameraView zoom>`.
212
+ */
213
+ deviceZoom: number | undefined;
169
214
  }
170
215
 
171
216
 
@@ -208,22 +253,61 @@ export function useCapture(options: UseCaptureOptions = {}): UseCaptureReturn {
208
253
  qualityThresholds,
209
254
  takePhotoOptions,
210
255
  preferredPhysicalDevice,
256
+ lens,
211
257
  } = options;
212
258
 
213
259
  const cameraRef = useRef<Camera | null>(null);
214
260
 
215
- // 2026-05-14 physical-lens-aware device picker.
261
+ const allDevices = useCameraDevices();
262
+
263
+ // v0.13.2 — capability-aware selection (`lens` supplied) vs legacy
264
+ // per-lens physical-device swap (`preferredPhysicalDevice`).
216
265
  //
217
- // When `preferredPhysicalDevice` is supplied, ask vision-camera
218
- // for a device that exposes that specific physical lens (e.g.,
219
- // 'ultra-wide-angle-camera'). Falls back to the position-default
220
- // when the device doesn't have that lens. When undefined, behaves
221
- // identically to the pre-2026-05-14 useCameraDevice(position) call.
222
- const deviceWithPreferred = useCameraDevice(cameraPosition, {
266
+ // Capability-aware: `selectCaptureDevice` prefers a multi-cam device
267
+ // that spans wide + ultra-wide (so 0.5× is reached via `zoom` and the
268
+ // torch works on every lens), falling back to a standalone ultra-wide
269
+ // device-swap only where the platform has no such multi-cam grouping.
270
+ // This fixes (a) 0.5× silently showing the wide-angle FOV on phones
271
+ // where the ultra-wide is only inside a multi-cam device, and (b)
272
+ // flash being unavailable on the torchless standalone ultra-wide.
273
+ const selection = useMemo(
274
+ () => selectCaptureDevice(allDevices as unknown as DeviceLike[]),
275
+ [allDevices],
276
+ );
277
+
278
+
279
+ // Legacy path (no `lens`): preserve the pre-v0.13.2 per-physical-lens
280
+ // request so direct Layer-2 hosts that pass `preferredPhysicalDevice`
281
+ // are unaffected.
282
+ const legacyDevice = useCameraDevice(cameraPosition, {
223
283
  physicalDevices: preferredPhysicalDevice ? [preferredPhysicalDevice] : undefined,
224
284
  });
225
- const deviceFallback = useCameraDevice(cameraPosition);
226
- const device = deviceWithPreferred ?? deviceFallback;
285
+ const legacyFallback = useCameraDevice(cameraPosition);
286
+
287
+ // The mounted device:
288
+ // - lens supplied + multicam → the single multi-cam device
289
+ // (lens switched via `zoom`, computed below).
290
+ // - lens supplied + standalone-uw→ swap to the ultra-wide device on
291
+ // 0.5×, else the wide primary (matches the legacy swap, but with
292
+ // correct device identity from `selectCaptureDevice`).
293
+ // - lens supplied + wide-only → the wide device (0.5× hidden).
294
+ // - no lens → legacy behaviour.
295
+ let device: ReturnType<typeof useCameraDevice>;
296
+ let activeZoom: number | undefined;
297
+ if (lens != null) {
298
+ if (selection.mode === 'standalone-uw' && lens === '0.5x') {
299
+ device = (selection.ultraWideDevice as typeof legacyDevice) ?? selection.device as typeof legacyDevice ?? legacyFallback;
300
+ } else {
301
+ device = (selection.device as typeof legacyDevice) ?? legacyFallback;
302
+ }
303
+ activeZoom =
304
+ selection.mode === 'multicam' && selection.device
305
+ ? zoomForLens(selection.device, lens)
306
+ : undefined;
307
+ } else {
308
+ device = legacyDevice ?? legacyFallback;
309
+ activeZoom = undefined;
310
+ }
227
311
 
228
312
  // Enumerate ALL physical lens types available on the chosen
229
313
  // position so the host can decide whether to render a switcher.
@@ -231,8 +315,8 @@ export function useCapture(options: UseCaptureOptions = {}): UseCaptureReturn {
231
315
  // has `physicalDevices: PhysicalCameraDeviceType[]`. We dedupe the
232
316
  // union across all devices at `position` so the host sees the full
233
317
  // set the platform exposes (some phones expose ultra-wide only via
234
- // a separate logical camera, not the main one).
235
- const allDevices = useCameraDevices();
318
+ // a separate logical camera, not the main one). `allDevices` is
319
+ // computed once above (shared with `selectCaptureDevice`).
236
320
  const availablePhysicalDevices = useMemo<PhysicalCameraDeviceType[]>(() => {
237
321
  const seen = new Set<PhysicalCameraDeviceType>();
238
322
  for (const d of allDevices) {
@@ -334,5 +418,9 @@ export function useCapture(options: UseCaptureOptions = {}): UseCaptureReturn {
334
418
  isCapturing,
335
419
  takePhoto,
336
420
  availablePhysicalDevices,
421
+ captureMode: selection.mode,
422
+ has0_5x: selection.has0_5x,
423
+ deviceHasTorch: device?.hasTorch ?? false,
424
+ deviceZoom: activeZoom,
337
425
  };
338
426
  }