react-native-image-stitcher 0.14.0 → 0.14.2
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 +39 -0
- package/README.md +211 -51
- package/RNImageStitcher.podspec +6 -0
- package/dist/ar/useARSession.d.ts +9 -0
- package/dist/ar/useARSession.js +24 -2
- package/dist/camera/Camera.js +17 -2
- package/package.json +1 -1
- package/src/ar/useARSession.ts +35 -5
- package/src/camera/Camera.tsx +20 -2
package/CHANGELOG.md
CHANGED
|
@@ -16,6 +16,45 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
16
16
|
|
|
17
17
|
## [Unreleased]
|
|
18
18
|
|
|
19
|
+
## [0.14.2] — 2026-06-03
|
|
20
|
+
|
|
21
|
+
### Fixed — AR preview blank on first entry (intermittent camera-handoff race)
|
|
22
|
+
|
|
23
|
+
`<Camera>` mounted the vision-camera preview before the device AR-support
|
|
24
|
+
probe (`isSupported()`) resolved: `isAvailable` starts `false`, so
|
|
25
|
+
`deriveEffectiveCaptureSource` returned `'non-ar'` and vision-camera's
|
|
26
|
+
AVCaptureSession grabbed the camera. When the probe resolved ~200-500 ms
|
|
27
|
+
later and the source flipped to AR, ARKit's `session.run()` raced the
|
|
28
|
+
still-open AVCaptureSession for the (mutually-exclusive) camera and lost
|
|
29
|
+
with `ARError "Required sensor failed."` — leaving a blank AR preview and an
|
|
30
|
+
"AR session has no current frame" error on the next capture. Being
|
|
31
|
+
timing-dependent it reproduced intermittently; toggling AR off→on recovered
|
|
32
|
+
(that path releases the camera cleanly first).
|
|
33
|
+
|
|
34
|
+
`useARSession` now exposes `supportProbed` (true once the one-shot
|
|
35
|
+
`isSupported()` probe settles — success or failure). `<Camera>` defers the
|
|
36
|
+
initial camera mount while AR is the intended source but support is still
|
|
37
|
+
unknown, rendering the "Switching camera…" placeholder instead of
|
|
38
|
+
vision-camera, so vision-camera never contends for the camera when AR is the
|
|
39
|
+
intent.
|
|
40
|
+
|
|
41
|
+
### Fixed — consumer iOS pod build pulled in the lib's C++ gtest unit tests
|
|
42
|
+
|
|
43
|
+
`RNImageStitcher.podspec`'s `cpp/**/*.{h,hpp,cpp}` glob slurped the lib's own
|
|
44
|
+
`cpp/tests/*.cpp` (which `#include <gtest/gtest.h>`) into every host pod
|
|
45
|
+
build, failing with `'gtest/gtest.h' file not found`. Added
|
|
46
|
+
`s.exclude_files = ['cpp/tests/**/*']`.
|
|
47
|
+
|
|
48
|
+
## [0.14.1] — 2026-06-01
|
|
49
|
+
|
|
50
|
+
### Docs
|
|
51
|
+
|
|
52
|
+
- Refresh the npm README for the v0.14 API: full `<Camera>` prop
|
|
53
|
+
reference (incl. `captureSources`), a complete capture-screen sample,
|
|
54
|
+
the portrait recommendation, and a 0.13.x → 0.14 migration note. (The
|
|
55
|
+
0.14.0 tarball shipped before this refresh landed; no code change.)
|
|
56
|
+
- Add a Docusaurus docs site (published to GitHub Pages).
|
|
57
|
+
|
|
19
58
|
## [0.14.0] — 2026-06-01
|
|
20
59
|
|
|
21
60
|
### Fixed — Android AR single-photo orientation (landscape was sideways)
|
package/README.md
CHANGED
|
@@ -8,10 +8,10 @@ AR-backed and IMU-fallback capture paths.
|
|
|
8
8
|
|
|
9
9
|
| Feature | Behaviour |
|
|
10
10
|
|---|---|
|
|
11
|
-
| **Tap shutter** | Single photo via vision-camera's `takePhoto` (non-AR) or `
|
|
11
|
+
| **Tap shutter** | Single photo via vision-camera's `takePhoto` (non-AR) or ARCore/ARKit `capturedImage` (AR). |
|
|
12
12
|
| **Hold shutter** | Panorama capture — pan and release. Engine accumulates keyframes; stitches via `cv::Stitcher::PANORAMA` (or `SCANS` if the pose suggests a flat-translation scan). |
|
|
13
|
-
| **Lens chip** | 1× / 0.5× toggle
|
|
14
|
-
| **AR
|
|
13
|
+
| **Lens chip** | 1× / 0.5× toggle next to the shutter. Shown only when the device actually has a usable ultra-wide (real capability detection, v0.14). Hidden entirely in AR-only mode (`captureSources="ar"`). |
|
|
14
|
+
| **Flash & AR pills** | Top-right pill stack, under the settings gear. Flash toggles the torch (hidden on torchless lenses, e.g. a standalone ultra-wide). AR pill toggles AR ↔ non-AR — shown only when `captureSources="both"` and the device supports AR. |
|
|
15
15
|
| **Internal settings panel** | Opt-in gear icon (top-right) via `showSettingsButton` prop. Exposes blender, seam finder, warper, flow-gate tunables — useful for internal testers; hidden from public consumers by default. |
|
|
16
16
|
|
|
17
17
|
## Installation
|
|
@@ -65,6 +65,15 @@ cd android && ./gradlew :app:assembleDebug # Android
|
|
|
65
65
|
|
|
66
66
|
## Quick start
|
|
67
67
|
|
|
68
|
+
> **Orientation: use portrait.** `<Camera>` is designed and tuned for
|
|
69
|
+
> portrait capture. On Android it self-locks to portrait; on iOS,
|
|
70
|
+
> portrait-only is the recommended host `Info.plist` configuration.
|
|
71
|
+
> See [Orientation support](#orientation-support) for the full story
|
|
72
|
+
> (landscape *is* supported on iOS if you need it).
|
|
73
|
+
|
|
74
|
+
The minimum: resolve camera permission, then mount `<Camera>` with an
|
|
75
|
+
`onCapture` handler.
|
|
76
|
+
|
|
68
77
|
```tsx
|
|
69
78
|
import {
|
|
70
79
|
Camera,
|
|
@@ -81,68 +90,213 @@ export function CaptureScreen() {
|
|
|
81
90
|
'Panorama:',
|
|
82
91
|
result.uri,
|
|
83
92
|
`${result.framesIncluded}/${result.framesRequested} frames`,
|
|
93
|
+
`stitched as ${result.stitchModeResolved ?? 'n/a'}`,
|
|
84
94
|
);
|
|
85
95
|
}
|
|
86
96
|
};
|
|
87
97
|
|
|
88
|
-
const handleError = (err: CameraError) => {
|
|
89
|
-
console.warn(err.code, err.message);
|
|
90
|
-
};
|
|
91
|
-
|
|
92
98
|
return (
|
|
93
99
|
<Camera
|
|
94
|
-
defaultCaptureSource="ar"
|
|
95
|
-
defaultLens="1x"
|
|
96
|
-
enablePhotoMode
|
|
97
|
-
enablePanoramaMode
|
|
98
100
|
onCapture={handleCapture}
|
|
99
|
-
onError={
|
|
101
|
+
onError={(err: CameraError) => console.warn(err.code, err.message)}
|
|
100
102
|
/>
|
|
101
103
|
);
|
|
102
104
|
}
|
|
103
105
|
```
|
|
104
106
|
|
|
105
|
-
|
|
107
|
+
### A complete capture screen
|
|
106
108
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
> the highlights only.
|
|
109
|
+
A realistic screen: requests permission up front, shows a capture
|
|
110
|
+
history strip, opens a post-stitch preview modal, and persists the
|
|
111
|
+
output to a directory you control. (The SDK does **not** request camera
|
|
112
|
+
permission for you — the host owns that.)
|
|
112
113
|
|
|
113
|
-
|
|
114
|
+
```tsx
|
|
115
|
+
import React, { useCallback, useEffect, useState } from 'react';
|
|
116
|
+
import { View, StyleSheet } from 'react-native';
|
|
117
|
+
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
|
118
|
+
import { useCameraPermission } from 'react-native-vision-camera';
|
|
119
|
+
import {
|
|
120
|
+
Camera,
|
|
121
|
+
type CameraCaptureResult,
|
|
122
|
+
type CameraError,
|
|
123
|
+
type CaptureThumbnailItem,
|
|
124
|
+
} from 'react-native-image-stitcher';
|
|
114
125
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
126
|
+
export function CaptureScreen() {
|
|
127
|
+
// 1. Camera permission is a HOST concern — resolve it BEFORE mounting
|
|
128
|
+
// <Camera>. (Android treats unrequested permissions as denied even
|
|
129
|
+
// when declared in the manifest, so the explicit call is required.)
|
|
130
|
+
const { hasPermission, requestPermission } = useCameraPermission();
|
|
131
|
+
useEffect(() => {
|
|
132
|
+
if (!hasPermission) requestPermission().catch(() => undefined);
|
|
133
|
+
}, [hasPermission, requestPermission]);
|
|
134
|
+
|
|
135
|
+
// 2. Capture history (drives the built-in thumbnail strip).
|
|
136
|
+
const [thumbnails, setThumbnails] = useState<CaptureThumbnailItem[]>([]);
|
|
137
|
+
|
|
138
|
+
// 3. Post-stitch preview modal — set on capture, cleared on close.
|
|
139
|
+
const [preview, setPreview] = useState<CameraCaptureResult | null>(null);
|
|
140
|
+
|
|
141
|
+
const onCapture = useCallback((result: CameraCaptureResult) => {
|
|
142
|
+
setPreview(result);
|
|
143
|
+
setThumbnails((prev) => [
|
|
144
|
+
...prev,
|
|
145
|
+
{ id: String(Date.now()), uri: result.uri, width: result.width, height: result.height },
|
|
146
|
+
]);
|
|
147
|
+
}, []);
|
|
148
|
+
|
|
149
|
+
if (!hasPermission) return <View style={styles.fill} />; // or your own "grant access" UI
|
|
150
|
+
|
|
151
|
+
return (
|
|
152
|
+
<SafeAreaProvider>
|
|
153
|
+
<View style={styles.fill}>
|
|
154
|
+
<Camera
|
|
155
|
+
// Capture-mode controls
|
|
156
|
+
defaultCaptureSource="ar" // start in AR mode (pose-driven)
|
|
157
|
+
captureSources="both" // allow AR + non-AR; show the AR toggle
|
|
158
|
+
enablePhotoMode // tap = photo
|
|
159
|
+
enablePanoramaMode // hold + pan = panorama
|
|
160
|
+
// Output
|
|
161
|
+
outputDir={`${/* your app dir */ ''}/captures`}
|
|
162
|
+
// Header chrome (optional)
|
|
163
|
+
headerTitle="Capture"
|
|
164
|
+
headerGuidance="Tap for a photo. Hold + pan + release for a panorama."
|
|
165
|
+
// Capture history strip
|
|
166
|
+
thumbnails={thumbnails}
|
|
167
|
+
// Post-stitch preview modal (controlled — clear it on close)
|
|
168
|
+
capturePreview={preview ? { imageUri: preview.uri } : undefined}
|
|
169
|
+
onCapturePreviewClose={() => setPreview(null)}
|
|
170
|
+
// Events
|
|
171
|
+
onCapture={onCapture}
|
|
172
|
+
onError={(err: CameraError) => console.warn(err.code, err.message)}
|
|
173
|
+
onCaptureAbandoned={(reason) => console.log('abandoned:', reason)}
|
|
174
|
+
/>
|
|
175
|
+
</View>
|
|
176
|
+
</SafeAreaProvider>
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const styles = StyleSheet.create({ fill: { flex: 1, backgroundColor: '#000' } });
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
## `<Camera>` props (full reference)
|
|
184
|
+
|
|
185
|
+
Every prop is optional. `<Camera>` works with no props at all (it just
|
|
186
|
+
captures and you wire `onCapture`). Props fall into seven groups.
|
|
187
|
+
|
|
188
|
+
> A deeper companion reference with composition recipes lives in
|
|
189
|
+
> [`docs/camera-component.md`](docs/camera-component.md). The tables
|
|
190
|
+
> below are the authoritative prop list.
|
|
191
|
+
|
|
192
|
+
### Capture-source & lens (uncontrolled — read once at mount)
|
|
193
|
+
|
|
194
|
+
| Prop | Type | Default | Notes |
|
|
195
|
+
|---|---|---|---|
|
|
196
|
+
| `defaultCaptureSource` | `'ar' \| 'non-ar'` | `'ar'` | Initial capture path. Clamped to `captureSources` (below). |
|
|
197
|
+
| `captureSources` | `'ar' \| 'non-ar' \| 'both'` | `'both'` | **(v0.14)** Which sources are allowed. `'both'` shows the AR toggle. `'ar'` hides the AR toggle **and** the lens chooser (ARKit/ARCore can't use the ultra-wide). `'non-ar'` hides the AR toggle, keeps the lens chooser. A single-source value overrides a conflicting `defaultCaptureSource`. |
|
|
198
|
+
| `defaultLens` | `'1x' \| '0.5x'` | `'1x'` | Initial lens. The 0.5× chooser only appears if the device actually has a usable ultra-wide (real capability detection, v0.14). |
|
|
199
|
+
|
|
200
|
+
### Panorama / stitcher tunables (uncontrolled — internal-tester knobs)
|
|
201
|
+
|
|
202
|
+
These mirror the in-app settings panel; most apps never set them.
|
|
203
|
+
|
|
204
|
+
| Prop | Type | Default | Notes |
|
|
205
|
+
|---|---|---|---|
|
|
206
|
+
| `defaultStitchMode` | `'auto' \| 'panorama' \| 'scans'` | `'auto'` | `'auto'` picks PANORAMA vs SCANS from the pose at finalize. |
|
|
207
|
+
| `defaultBlender` | `'multiband' \| 'feather'` | `'multiband'` | cv::Stitcher blender. |
|
|
208
|
+
| `defaultSeamFinder` | `'graphcut' \| 'skip'` | `'graphcut'` | Seam finder. |
|
|
209
|
+
| `defaultWarper` | `'plane' \| 'cylindrical' \| 'spherical'` | `'plane'` | Projection surface. |
|
|
210
|
+
| `defaultFlowNoveltyPercentile` | `number` | `0.85` | Keyframe-gate novelty threshold (0.50–0.99). |
|
|
211
|
+
| `defaultFlowEvalEveryNFrames` | `number` | `5` | Flow-gate eval cadence (1–10). |
|
|
212
|
+
| `defaultFlowMaxTranslationCm` | `number` | `50` | Max IMU translation between keyframes; 0 = disabled. |
|
|
213
|
+
| `defaultKeyframeMaxCount` | `number` | `6` | Keyframe cap per capture (3–10). |
|
|
214
|
+
| `defaultKeyframeOverlapThreshold` | `number` | `0.20` | Min overlap to accept a keyframe (0.20–0.60). |
|
|
215
|
+
| `defaultCompositingResolMP` / `defaultRegistrationResolMP` / `defaultSeamEstimationResolMP` | `number` | — | Forward-looking cv::Stitcher resolution knobs (currently no-ops). |
|
|
128
216
|
|
|
129
217
|
### UI toggles
|
|
130
218
|
|
|
131
|
-
| Prop | Default | Notes |
|
|
219
|
+
| Prop | Type | Default | Notes |
|
|
220
|
+
|---|---|---|---|
|
|
221
|
+
| `enablePhotoMode` | `boolean` | `true` | Tap = photo. When false, tap is a no-op. |
|
|
222
|
+
| `enablePanoramaMode` | `boolean` | `true` | Hold + pan = panorama. When false, hold is a no-op. |
|
|
223
|
+
| `showSettingsButton` | `boolean` | `false` | Gear icon → internal settings panel. Internal-tester only; leave off for public consumers. |
|
|
224
|
+
| `style` | `StyleProp<ViewStyle>` | — | Outer container style. |
|
|
225
|
+
|
|
226
|
+
### Flash (controlled or uncontrolled)
|
|
227
|
+
|
|
228
|
+
| Prop | Type | Default | Notes |
|
|
229
|
+
|---|---|---|---|
|
|
230
|
+
| `flash` | `'on' \| 'off'` | — | Controlled torch state. Omit to let `<Camera>` own it internally. |
|
|
231
|
+
| `onFlashChange` | `(next: 'on' \| 'off') => void` | — | Fires on flash-button tap (controlled and uncontrolled). |
|
|
232
|
+
| `showFlashButton` | `boolean` | `true` | Built-in flash pill (top-right). Auto-hidden when the mounted device has no torch (e.g. a standalone ultra-wide) and in AR mode. |
|
|
233
|
+
|
|
234
|
+
### Header chrome (opt-in)
|
|
235
|
+
|
|
236
|
+
Setting `headerTitle` renders a built-in top header; the settings gear is absorbed into it.
|
|
237
|
+
|
|
238
|
+
| Prop | Type | Notes |
|
|
132
239
|
|---|---|---|
|
|
133
|
-
| `
|
|
134
|
-
| `
|
|
135
|
-
| `
|
|
240
|
+
| `headerTitle` | `string` | Shows the header when set. |
|
|
241
|
+
| `headerGuidance` | `string` | Subtitle / guidance pill under the title. |
|
|
242
|
+
| `onHeaderBack` | `() => void` | Renders a back affordance when provided. |
|
|
243
|
+
| `headerBackLabel` | `string` | Custom back-button label. |
|
|
244
|
+
| `headerColors` | `object` | Override header colours. |
|
|
136
245
|
|
|
137
|
-
###
|
|
246
|
+
### Capture history + post-stitch preview
|
|
138
247
|
|
|
139
|
-
| Prop |
|
|
140
|
-
|
|
141
|
-
| `
|
|
142
|
-
| `
|
|
143
|
-
| `
|
|
144
|
-
| `
|
|
145
|
-
| `
|
|
248
|
+
| Prop | Type | Notes |
|
|
249
|
+
|---|---|---|
|
|
250
|
+
| `thumbnails` | `CaptureThumbnailItem[]` | When supplied (even `[]`), renders the built-in thumbnail strip. Hidden during recording. |
|
|
251
|
+
| `thumbnailsMin` / `thumbnailsMax` | `number` | Optional count-line hints (e.g. quota guidance). |
|
|
252
|
+
| `onThumbnailPress` | `(item) => void` | Replaces the strip's built-in tap-to-preview with your handler. |
|
|
253
|
+
| `capturePreview` | `{ imageUri; imageWidth?; imageHeight?; title? }` | When set, renders the built-in preview modal. Controlled — clear it via `onCapturePreviewClose`. |
|
|
254
|
+
| `capturePreviewActions` | `CapturePreviewAction[]` | Action buttons for the preview modal (e.g. Save / Retake). |
|
|
255
|
+
| `onCapturePreviewClose` | `() => void` | Fires when the preview modal is dismissed. |
|
|
256
|
+
|
|
257
|
+
### Callbacks & advanced
|
|
258
|
+
|
|
259
|
+
| Prop | Type | Fires / purpose |
|
|
260
|
+
|---|---|---|
|
|
261
|
+
| `onCapture` | `(result: CameraCaptureResult) => void` | Photo OR panorama completes. `result.type` discriminates (`'photo'` / `'panorama'`). |
|
|
262
|
+
| `onCaptureSourceChange` | `(source: CaptureSource) => void` | Effective source changes (AR toggle, or 0.5× forcing non-AR). |
|
|
263
|
+
| `onLensChange` | `(lens: CameraLens) => void` | User taps the 1×/0.5× chip. |
|
|
264
|
+
| `onFramesDropped` | `(info: FramesDroppedInfo) => void` | cv::Stitcher's confidence retry dropped input frame(s). |
|
|
265
|
+
| `onCaptureAbandoned` | `(reason: 'orientation-drift') => void` | SDK auto-cancelled an in-flight capture (currently only mid-capture rotation). |
|
|
266
|
+
| `onError` | `(err: CameraError) => void` | Classified error — see codes below. |
|
|
267
|
+
| `outputDir` | `string` | Directory for saved JPEGs. The lib creates it if missing. |
|
|
268
|
+
| `engine` | `'batch-keyframe' \| …` | Stitching engine. Default `'batch-keyframe'`; most apps leave it. |
|
|
269
|
+
| `frameProcessor` | vision-camera frame processor | Host worklet composed with first-party stitching (see [`useStitcherWorklet`](docs/camera-component.md)). Advanced. |
|
|
270
|
+
|
|
271
|
+
### `CameraCaptureResult`
|
|
272
|
+
|
|
273
|
+
```ts
|
|
274
|
+
type CameraCaptureResult =
|
|
275
|
+
| { type: 'photo'; uri: string; width: number; height: number }
|
|
276
|
+
| { type: 'panorama'; uri: string; width: number; height: number;
|
|
277
|
+
framesRequested: number; framesIncluded: number; framesDropped: number;
|
|
278
|
+
finalConfidenceThresh: number; durationMs: number;
|
|
279
|
+
stitchModeResolved?: 'panorama' | 'scans' };
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
### `CameraError` codes
|
|
283
|
+
|
|
284
|
+
`err.code` is one of a fixed taxonomy so you can branch (toast vs retry vs report):
|
|
285
|
+
`CAMERA_PERMISSION_DENIED`, `CAMERA_DEVICE_UNAVAILABLE`, `PHOTO_CAPTURE_FAILED`,
|
|
286
|
+
`PANORAMA_START_FAILED`, `PANORAMA_FINALIZE_FAILED`, `STITCH_NEED_MORE_IMGS`,
|
|
287
|
+
`STITCH_HOMOGRAPHY_FAIL`, `STITCH_CAMERA_PARAMS_FAIL`, `STITCH_OOM`,
|
|
288
|
+
`OUTPUT_WRITE_FAILED`, plus `VISION_CAMERA_RUNTIME`.
|
|
289
|
+
|
|
290
|
+
### Migration from 0.13.x
|
|
291
|
+
|
|
292
|
+
- **Removed:** the `panGuide` and `panoramaGuidance` props (the
|
|
293
|
+
drift-marker overlay + pan-speed pill). They are no longer part of the
|
|
294
|
+
public API and `<Camera>` no longer renders them. Remove these props
|
|
295
|
+
if you were passing them — they're now a no-op type error.
|
|
296
|
+
- **Added:** `captureSources` (above).
|
|
297
|
+
- **Behaviour:** flash + AR controls moved to a top-right pill stack; the
|
|
298
|
+
0.5× chooser now reflects real device capability; Android self-locks to
|
|
299
|
+
portrait. No code change required for any of these.
|
|
146
300
|
|
|
147
301
|
## Orientation support
|
|
148
302
|
|
|
@@ -186,14 +340,20 @@ the modal alone.
|
|
|
186
340
|
|
|
187
341
|
## Lens ↔ AR interaction
|
|
188
342
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
| Initial mount with defaults | `true` | `1x` | AR toggle ON |
|
|
192
|
-
| User switches to 0.5× | unchanged (`true`) | `0.5x` | AR toggle HIDDEN, forced non-AR |
|
|
193
|
-
| User switches back to 1× | unchanged (`true`) | `1x` | AR toggle visible at its previous state |
|
|
194
|
-
| User taps AR toggle off (on 1×) | `false` | `1x` | AR toggle OFF |
|
|
343
|
+
The lens chooser and AR toggle interact, because ARKit/ARCore sessions
|
|
344
|
+
can't switch to the ultra-wide. With `captureSources="both"` (default):
|
|
195
345
|
|
|
196
|
-
|
|
346
|
+
| Action | AR preference | Lens | UI |
|
|
347
|
+
|---|---|---|---|
|
|
348
|
+
| Initial mount (defaults) | on | `1×` | AR pill ON |
|
|
349
|
+
| Switch to 0.5× | unchanged | `0.5×` | AR pill HIDDEN; capture forced non-AR |
|
|
350
|
+
| Switch back to 1× | unchanged | `1×` | AR pill visible at its previous state |
|
|
351
|
+
| Tap AR pill off (on 1×) | off | `1×` | AR pill OFF |
|
|
352
|
+
|
|
353
|
+
When `captureSources` is `'ar'` or `'non-ar'`, the AR pill never shows
|
|
354
|
+
(nothing to toggle), and `'ar'` additionally hides the lens chooser. The
|
|
355
|
+
component owns this runtime state; persist across launches via the
|
|
356
|
+
`on*Change` callbacks if desired.
|
|
197
357
|
|
|
198
358
|
## Architecture notes
|
|
199
359
|
|
package/RNImageStitcher.podspec
CHANGED
|
@@ -37,6 +37,12 @@ Pod::Spec.new do |s|
|
|
|
37
37
|
# (cpp/) that both iOS and Android compile from a single source.
|
|
38
38
|
s.source_files = ['ios/Sources/**/*.{swift,h,m,mm}',
|
|
39
39
|
'cpp/**/*.{h,hpp,cpp}']
|
|
40
|
+
# Exclude the lib's own C++ unit tests — they #include <gtest/gtest.h>,
|
|
41
|
+
# which consumer apps don't vendor. The `cpp/**/*.cpp` glob above
|
|
42
|
+
# otherwise slurps cpp/tests/*.cpp into every host pod build, failing
|
|
43
|
+
# with `'gtest/gtest.h' file not found`. Tests build only in the lib's
|
|
44
|
+
# CI / example app, never in a consumer.
|
|
45
|
+
s.exclude_files = ['cpp/tests/**/*']
|
|
40
46
|
# Restrict the umbrella header to ONLY the iOS-side Obj-C `.h`
|
|
41
47
|
# files. Without this, CocoaPods defaults every header in
|
|
42
48
|
# `source_files` (including the C++ `.hpp` files under cpp/) to
|
|
@@ -67,6 +67,15 @@ export interface UseARSessionReturn {
|
|
|
67
67
|
* older iPhones, simulators, and unsupported Android devices.
|
|
68
68
|
*/
|
|
69
69
|
isAvailable: boolean;
|
|
70
|
+
/**
|
|
71
|
+
* Whether the one-shot `isSupported()` probe has resolved (success OR
|
|
72
|
+
* failure). `false` only during the brief async window right after
|
|
73
|
+
* mount; `true` thereafter. Lets consumers distinguish "AR not
|
|
74
|
+
* supported" (probed && !isAvailable) from "support not yet known"
|
|
75
|
+
* (!probed), so they don't prematurely mount the non-AR camera and
|
|
76
|
+
* lose a camera-handoff race when AR is the intended source.
|
|
77
|
+
*/
|
|
78
|
+
supportProbed: boolean;
|
|
70
79
|
/**
|
|
71
80
|
* Whether the session is currently running. True between
|
|
72
81
|
* `start()` and `stop()`.
|
package/dist/ar/useARSession.js
CHANGED
|
@@ -56,6 +56,7 @@ function getNativeModule() {
|
|
|
56
56
|
const STATE_POLL_INTERVAL_MS = 500;
|
|
57
57
|
function useARSession() {
|
|
58
58
|
const [isAvailable, setIsAvailable] = (0, react_1.useState)(false);
|
|
59
|
+
const [supportProbed, setSupportProbed] = (0, react_1.useState)(false);
|
|
59
60
|
const [isRunning, setIsRunning] = (0, react_1.useState)(false);
|
|
60
61
|
const [trackingState, setTrackingState] = (0, react_1.useState)(ARTrackingState.NotAvailable);
|
|
61
62
|
const pollRef = (0, react_1.useRef)(null);
|
|
@@ -64,12 +65,32 @@ function useARSession() {
|
|
|
64
65
|
// AR support shouldn't crash anything — `isAvailable` stays
|
|
65
66
|
// false and the rest of the SDK falls back to vision-camera.
|
|
66
67
|
(0, react_1.useEffect)(() => {
|
|
67
|
-
if (!native)
|
|
68
|
+
if (!native) {
|
|
69
|
+
// No native module at all — treat the probe as resolved
|
|
70
|
+
// (unsupported) so consumers don't wait forever for AR.
|
|
71
|
+
setSupportProbed(true);
|
|
68
72
|
return;
|
|
69
|
-
|
|
73
|
+
}
|
|
74
|
+
let cancelled = false;
|
|
75
|
+
native
|
|
76
|
+
.isSupported()
|
|
77
|
+
.then((ok) => {
|
|
78
|
+
if (!cancelled)
|
|
79
|
+
setIsAvailable(ok);
|
|
80
|
+
})
|
|
81
|
+
.catch((err) => {
|
|
70
82
|
// eslint-disable-next-line no-console
|
|
71
83
|
console.warn('[useARSession] isSupported failed', err);
|
|
84
|
+
})
|
|
85
|
+
.finally(() => {
|
|
86
|
+
// Mark the probe resolved either way so the non-AR fallback
|
|
87
|
+
// (or AR mount) can proceed exactly once support is known.
|
|
88
|
+
if (!cancelled)
|
|
89
|
+
setSupportProbed(true);
|
|
72
90
|
});
|
|
91
|
+
return () => {
|
|
92
|
+
cancelled = true;
|
|
93
|
+
};
|
|
73
94
|
}, [native]);
|
|
74
95
|
const stopPolling = (0, react_1.useCallback)(() => {
|
|
75
96
|
if (pollRef.current !== null) {
|
|
@@ -122,6 +143,7 @@ function useARSession() {
|
|
|
122
143
|
}, [native]);
|
|
123
144
|
return {
|
|
124
145
|
isAvailable,
|
|
146
|
+
supportProbed,
|
|
125
147
|
isRunning,
|
|
126
148
|
trackingState,
|
|
127
149
|
start,
|
package/dist/camera/Camera.js
CHANGED
|
@@ -328,10 +328,25 @@ function Camera(props) {
|
|
|
328
328
|
// (older iPhones, ARCore-less Androids, simulators) stay `false`
|
|
329
329
|
// forever, which forces non-AR capture everywhere and hides the
|
|
330
330
|
// AR toggle in the bottom bar (see JSX below).
|
|
331
|
-
const { isAvailable: isARSupportedOnDevice } = (0, useARSession_1.useARSession)();
|
|
331
|
+
const { isAvailable: isARSupportedOnDevice, supportProbed: isARSupportProbed } = (0, useARSession_1.useARSession)();
|
|
332
332
|
const effectiveCaptureSource = deriveEffectiveCaptureSource(arPreference, lens, isARSupportedOnDevice);
|
|
333
333
|
const isAR = effectiveCaptureSource === 'ar';
|
|
334
334
|
const isNonAR = !isAR;
|
|
335
|
+
// v0.14.2 — camera-handoff race guard. While AR is the preferred
|
|
336
|
+
// source but the one-shot `isSupported()` probe hasn't resolved yet,
|
|
337
|
+
// `deriveEffectiveCaptureSource` returns 'non-ar' (because
|
|
338
|
+
// `isARSupportedOnDevice` is still false), which would mount
|
|
339
|
+
// <CameraView> and let vision-camera's AVCaptureSession grab the
|
|
340
|
+
// camera. The switch to AR ~200-500ms later then fails with ARKit
|
|
341
|
+
// "Required sensor failed" (ARKit and AVCaptureSession can't share the
|
|
342
|
+
// camera), leaving a blank AR preview — intermittent and timing-
|
|
343
|
+
// dependent. Defer the initial mount until the probe settles: while
|
|
344
|
+
// pending we render the "Switching camera…" placeholder instead of any
|
|
345
|
+
// camera, so vision-camera never contends for the device when AR is the
|
|
346
|
+
// intent. Conditions mirror deriveEffectiveCaptureSource's own
|
|
347
|
+
// non-support gates (arPreference, lens) so this is true in exactly the
|
|
348
|
+
// cases that resolve to AR once support is confirmed.
|
|
349
|
+
const arSupportPending = arPreference && lens !== '0.5x' && !isARSupportProbed;
|
|
335
350
|
const deviceOrientation = (0, useDeviceOrientation_1.useDeviceOrientation)();
|
|
336
351
|
// v0.13.1 — counter-rotation for control CONTENT (AR toggle, lens
|
|
337
352
|
// pill, flash icon, thumbnails) so their labels read upright relative
|
|
@@ -970,7 +985,7 @@ function Camera(props) {
|
|
|
970
985
|
: insets.top + 8;
|
|
971
986
|
// ── JSX ─────────────────────────────────────────────────────────
|
|
972
987
|
return (react_1.default.createElement(react_native_1.View, { style: [styles.container, style] },
|
|
973
|
-
inFlightTransition ? (react_1.default.createElement(react_native_1.View, { style: [react_native_1.StyleSheet.absoluteFill, styles.transitionPlaceholder] },
|
|
988
|
+
inFlightTransition || arSupportPending ? (react_1.default.createElement(react_native_1.View, { style: [react_native_1.StyleSheet.absoluteFill, styles.transitionPlaceholder] },
|
|
974
989
|
react_1.default.createElement(react_native_1.Text, { style: styles.transitionLabel }, "Switching camera\u2026"))) : isAR ? (react_1.default.createElement(ARCameraView_1.ARCameraView, { ref: arViewRef, style: react_native_1.StyleSheet.absoluteFill })) : (react_1.default.createElement(CameraView_1.CameraView, { ref: visionCameraRef, device: capture.device, isActive: true,
|
|
975
990
|
// `video={true}` is REQUIRED for takeSnapshot to work on iOS.
|
|
976
991
|
// vision-camera v4's iOS implementation of takeSnapshot waits
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-native-image-stitcher",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.2",
|
|
4
4
|
"description": "Pose-aware panorama capture + stitching for React Native. One <Camera> component, both tap-to-photo and hold-to-pan modes, both AR-backed and IMU-fallback capture paths.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
package/src/ar/useARSession.ts
CHANGED
|
@@ -69,6 +69,15 @@ export interface UseARSessionReturn {
|
|
|
69
69
|
* older iPhones, simulators, and unsupported Android devices.
|
|
70
70
|
*/
|
|
71
71
|
isAvailable: boolean;
|
|
72
|
+
/**
|
|
73
|
+
* Whether the one-shot `isSupported()` probe has resolved (success OR
|
|
74
|
+
* failure). `false` only during the brief async window right after
|
|
75
|
+
* mount; `true` thereafter. Lets consumers distinguish "AR not
|
|
76
|
+
* supported" (probed && !isAvailable) from "support not yet known"
|
|
77
|
+
* (!probed), so they don't prematurely mount the non-AR camera and
|
|
78
|
+
* lose a camera-handoff race when AR is the intended source.
|
|
79
|
+
*/
|
|
80
|
+
supportProbed: boolean;
|
|
72
81
|
/**
|
|
73
82
|
* Whether the session is currently running. True between
|
|
74
83
|
* `start()` and `stop()`.
|
|
@@ -128,6 +137,7 @@ const STATE_POLL_INTERVAL_MS = 500;
|
|
|
128
137
|
|
|
129
138
|
export function useARSession(): UseARSessionReturn {
|
|
130
139
|
const [isAvailable, setIsAvailable] = useState(false);
|
|
140
|
+
const [supportProbed, setSupportProbed] = useState(false);
|
|
131
141
|
const [isRunning, setIsRunning] = useState(false);
|
|
132
142
|
const [trackingState, setTrackingState] = useState<ARTrackingState>(
|
|
133
143
|
ARTrackingState.NotAvailable,
|
|
@@ -140,11 +150,30 @@ export function useARSession(): UseARSessionReturn {
|
|
|
140
150
|
// AR support shouldn't crash anything — `isAvailable` stays
|
|
141
151
|
// false and the rest of the SDK falls back to vision-camera.
|
|
142
152
|
useEffect(() => {
|
|
143
|
-
if (!native)
|
|
144
|
-
|
|
145
|
-
//
|
|
146
|
-
|
|
147
|
-
|
|
153
|
+
if (!native) {
|
|
154
|
+
// No native module at all — treat the probe as resolved
|
|
155
|
+
// (unsupported) so consumers don't wait forever for AR.
|
|
156
|
+
setSupportProbed(true);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
let cancelled = false;
|
|
160
|
+
native
|
|
161
|
+
.isSupported()
|
|
162
|
+
.then((ok) => {
|
|
163
|
+
if (!cancelled) setIsAvailable(ok);
|
|
164
|
+
})
|
|
165
|
+
.catch((err) => {
|
|
166
|
+
// eslint-disable-next-line no-console
|
|
167
|
+
console.warn('[useARSession] isSupported failed', err);
|
|
168
|
+
})
|
|
169
|
+
.finally(() => {
|
|
170
|
+
// Mark the probe resolved either way so the non-AR fallback
|
|
171
|
+
// (or AR mount) can proceed exactly once support is known.
|
|
172
|
+
if (!cancelled) setSupportProbed(true);
|
|
173
|
+
});
|
|
174
|
+
return () => {
|
|
175
|
+
cancelled = true;
|
|
176
|
+
};
|
|
148
177
|
}, [native]);
|
|
149
178
|
|
|
150
179
|
const stopPolling = useCallback(() => {
|
|
@@ -200,6 +229,7 @@ export function useARSession(): UseARSessionReturn {
|
|
|
200
229
|
|
|
201
230
|
return {
|
|
202
231
|
isAvailable,
|
|
232
|
+
supportProbed,
|
|
203
233
|
isRunning,
|
|
204
234
|
trackingState,
|
|
205
235
|
start,
|
package/src/camera/Camera.tsx
CHANGED
|
@@ -979,7 +979,8 @@ export function Camera(props: CameraProps): React.JSX.Element {
|
|
|
979
979
|
// (older iPhones, ARCore-less Androids, simulators) stay `false`
|
|
980
980
|
// forever, which forces non-AR capture everywhere and hides the
|
|
981
981
|
// AR toggle in the bottom bar (see JSX below).
|
|
982
|
-
const { isAvailable: isARSupportedOnDevice } =
|
|
982
|
+
const { isAvailable: isARSupportedOnDevice, supportProbed: isARSupportProbed } =
|
|
983
|
+
useARSession();
|
|
983
984
|
|
|
984
985
|
const effectiveCaptureSource = deriveEffectiveCaptureSource(
|
|
985
986
|
arPreference,
|
|
@@ -988,6 +989,23 @@ export function Camera(props: CameraProps): React.JSX.Element {
|
|
|
988
989
|
);
|
|
989
990
|
const isAR = effectiveCaptureSource === 'ar';
|
|
990
991
|
const isNonAR = !isAR;
|
|
992
|
+
|
|
993
|
+
// v0.14.2 — camera-handoff race guard. While AR is the preferred
|
|
994
|
+
// source but the one-shot `isSupported()` probe hasn't resolved yet,
|
|
995
|
+
// `deriveEffectiveCaptureSource` returns 'non-ar' (because
|
|
996
|
+
// `isARSupportedOnDevice` is still false), which would mount
|
|
997
|
+
// <CameraView> and let vision-camera's AVCaptureSession grab the
|
|
998
|
+
// camera. The switch to AR ~200-500ms later then fails with ARKit
|
|
999
|
+
// "Required sensor failed" (ARKit and AVCaptureSession can't share the
|
|
1000
|
+
// camera), leaving a blank AR preview — intermittent and timing-
|
|
1001
|
+
// dependent. Defer the initial mount until the probe settles: while
|
|
1002
|
+
// pending we render the "Switching camera…" placeholder instead of any
|
|
1003
|
+
// camera, so vision-camera never contends for the device when AR is the
|
|
1004
|
+
// intent. Conditions mirror deriveEffectiveCaptureSource's own
|
|
1005
|
+
// non-support gates (arPreference, lens) so this is true in exactly the
|
|
1006
|
+
// cases that resolve to AR once support is confirmed.
|
|
1007
|
+
const arSupportPending =
|
|
1008
|
+
arPreference && lens !== '0.5x' && !isARSupportProbed;
|
|
991
1009
|
const deviceOrientation = useDeviceOrientation();
|
|
992
1010
|
|
|
993
1011
|
// v0.13.1 — counter-rotation for control CONTENT (AR toggle, lens
|
|
@@ -1690,7 +1708,7 @@ export function Camera(props: CameraProps): React.JSX.Element {
|
|
|
1690
1708
|
only ONE camera component is alive at a time; matches the
|
|
1691
1709
|
monorepo's working pattern and avoids the Camera2-in-use
|
|
1692
1710
|
conflict that "always mount both" caused on Android. */}
|
|
1693
|
-
{inFlightTransition ? (
|
|
1711
|
+
{inFlightTransition || arSupportPending ? (
|
|
1694
1712
|
<View style={[StyleSheet.absoluteFill, styles.transitionPlaceholder]}>
|
|
1695
1713
|
<Text style={styles.transitionLabel}>Switching camera…</Text>
|
|
1696
1714
|
</View>
|