react-native-image-stitcher 0.14.1 → 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 CHANGED
@@ -16,6 +16,35 @@ 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
+
19
48
  ## [0.14.1] — 2026-06-01
20
49
 
21
50
  ### Docs
@@ -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()`.
@@ -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
- native.isSupported().then(setIsAvailable).catch((err) => {
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,
@@ -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.1",
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",
@@ -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) return;
144
- native.isSupported().then(setIsAvailable).catch((err) => {
145
- // eslint-disable-next-line no-console
146
- console.warn('[useARSession] isSupported failed', err);
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,
@@ -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 } = useARSession();
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>