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.
- package/CHANGELOG.md +181 -0
- package/README.md +33 -17
- package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +33 -5
- package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +73 -1
- package/dist/camera/Camera.d.ts +226 -0
- package/dist/camera/Camera.js +208 -20
- package/dist/camera/CameraView.d.ts +6 -0
- package/dist/camera/CameraView.js +2 -2
- package/dist/camera/CaptureHeader.js +39 -16
- package/dist/camera/CapturePreview.js +13 -1
- package/dist/camera/CaptureThumbnailStrip.d.ts +25 -1
- package/dist/camera/CaptureThumbnailStrip.js +17 -4
- package/dist/camera/PanoramaBandOverlay.d.ts +76 -0
- package/dist/camera/PanoramaBandOverlay.js +90 -33
- package/dist/camera/PanoramaConfirmModal.js +11 -1
- package/dist/camera/selectCaptureDevice.d.ts +93 -0
- package/dist/camera/selectCaptureDevice.js +131 -0
- package/dist/camera/useCapture.d.ts +40 -0
- package/dist/camera/useCapture.js +50 -12
- package/dist/camera/useContentRotation.d.ts +99 -0
- package/dist/camera/useContentRotation.js +124 -0
- package/dist/index.d.ts +1 -3
- package/dist/index.js +6 -5
- package/package.json +1 -1
- package/src/camera/Camera.tsx +546 -32
- package/src/camera/CameraView.tsx +9 -0
- package/src/camera/CaptureHeader.tsx +39 -16
- package/src/camera/CapturePreview.tsx +12 -0
- package/src/camera/CaptureThumbnailStrip.tsx +44 -4
- package/src/camera/PanoramaBandOverlay.tsx +97 -35
- package/src/camera/PanoramaConfirmModal.tsx +10 -0
- package/src/camera/__tests__/bandThumbRotation.test.ts +120 -0
- package/src/camera/__tests__/homeIndicatorEdge.test.ts +116 -0
- package/src/camera/__tests__/selectCaptureDevice.test.ts +177 -0
- package/src/camera/__tests__/useContentRotation.test.ts +89 -0
- package/src/camera/selectCaptureDevice.ts +187 -0
- package/src/camera/useCapture.ts +99 -11
- package/src/camera/useContentRotation.ts +149 -0
- 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
|
+
}
|
package/src/camera/useCapture.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
|
|
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
|
|
226
|
-
|
|
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
|
-
|
|
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
|
}
|