react-native-image-stitcher 0.11.1 → 0.13.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 (37) hide show
  1. package/CHANGELOG.md +151 -0
  2. package/README.md +28 -0
  3. package/android/src/main/java/io/imagestitcher/rn/IncrementalFirstwinsEngine.kt +3 -2
  4. package/dist/camera/ARCameraView.d.ts +10 -0
  5. package/dist/camera/ARCameraView.js +1 -0
  6. package/dist/camera/Camera.d.ts +191 -0
  7. package/dist/camera/Camera.js +250 -9
  8. package/dist/camera/OrientationDriftModal.d.ts +83 -0
  9. package/dist/camera/OrientationDriftModal.js +159 -0
  10. package/dist/camera/PanoramaBandOverlay.d.ts +13 -1
  11. package/dist/camera/PanoramaBandOverlay.js +106 -45
  12. package/dist/camera/PanoramaSettingsModal.js +15 -1
  13. package/dist/camera/ViewportCropOverlay.d.ts +35 -31
  14. package/dist/camera/ViewportCropOverlay.js +39 -30
  15. package/dist/camera/useDeviceOrientation.d.ts +18 -9
  16. package/dist/camera/useDeviceOrientation.js +18 -9
  17. package/dist/camera/useOrientationDrift.d.ts +104 -0
  18. package/dist/camera/useOrientationDrift.js +120 -0
  19. package/dist/index.d.ts +5 -0
  20. package/dist/index.js +12 -1
  21. package/dist/stitching/incremental.d.ts +5 -3
  22. package/ios/Sources/RNImageStitcher/ARSessionBridge.swift +7 -1
  23. package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.h +4 -3
  24. package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.mm +9 -7
  25. package/ios/Sources/RNImageStitcher/RNSARSession.swift +46 -7
  26. package/package.json +1 -1
  27. package/src/camera/ARCameraView.tsx +18 -1
  28. package/src/camera/Camera.tsx +639 -21
  29. package/src/camera/OrientationDriftModal.tsx +224 -0
  30. package/src/camera/PanoramaBandOverlay.tsx +135 -49
  31. package/src/camera/PanoramaSettingsModal.tsx +14 -0
  32. package/src/camera/ViewportCropOverlay.tsx +52 -30
  33. package/src/camera/__tests__/useOrientationDrift.test.ts +169 -0
  34. package/src/camera/useDeviceOrientation.ts +18 -9
  35. package/src/camera/useOrientationDrift.ts +172 -0
  36. package/src/index.ts +13 -0
  37. package/src/stitching/incremental.ts +5 -3
@@ -0,0 +1,120 @@
1
+ "use strict";
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * useOrientationDrift — detects mid-capture device rotation.
5
+ *
6
+ * Pairs with `useDeviceOrientation()` to surface the case where the
7
+ * user rotates the device *during* an active capture. The
8
+ * incremental stitching engine supports both portrait (Mode B,
9
+ * horizontal pan) and landscape (Mode A, vertical pan) capture
10
+ * modes as first-class — but mixing them mid-capture produces
11
+ * malformed output ("cross-mode capture is best-effort," per
12
+ * `incremental.ts:373-403`). Hosts that want to protect against
13
+ * this use this hook + `OrientationDriftModal` together: the
14
+ * `<Camera>` flagship component auto-abandons capture the instant
15
+ * `drifted === true` (PR-2 wiring); the modal surfaces an
16
+ * explanatory popup to the user.
17
+ *
18
+ * ## API contract
19
+ *
20
+ * Pass `active` true while a capture is in flight, false otherwise.
21
+ * Returns:
22
+ *
23
+ * - `captureOrientation` — the orientation snapshotted at the
24
+ * moment `active` transitioned false → true. `undefined` when
25
+ * `active` is false.
26
+ * - `currentOrientation` — live orientation from
27
+ * `useDeviceOrientation()`. Always defined (defaults to
28
+ * `'portrait'` until the accelerometer's first sample).
29
+ * - `drifted` — `true` IFF `active` is currently true AND
30
+ * `currentOrientation !== captureOrientation` at some point
31
+ * since the snapshot. **Latching** — once true, stays true
32
+ * until `active` flips back to false. This is intentional:
33
+ * after detection, callers should auto-abandon the capture
34
+ * (engine `stop()`); allowing the flag to clear before then
35
+ * would mask the drift if the user rotated back to the
36
+ * original orientation between the detection tick and the
37
+ * callers' abandonment effect.
38
+ *
39
+ * ## Semantics by transition
40
+ *
41
+ * - `active` false → true: snapshot `currentOrientation`;
42
+ * reset `drifted` to false.
43
+ * - `active` true (steady): if `currentOrientation !==
44
+ * captureOrientation` at any point, latch `drifted = true`.
45
+ * - `active` true → false: clear snapshot; reset `drifted`.
46
+ *
47
+ * ## Why a separate hook (rather than inlining in `<Camera>`)
48
+ *
49
+ * Hosts using the Layer-2 building blocks (`CameraView` directly,
50
+ * custom capture UX) can reuse this hook without mounting the
51
+ * full `<Camera>` flagship. Same composition pattern as
52
+ * `useIMUTranslationGate` and `useKeyframeStream`.
53
+ *
54
+ * ## Testing
55
+ *
56
+ * The pure state-transition function `_computeDriftStateForTests`
57
+ * is exported separately so jest can exercise all 5 transition
58
+ * cases without booting a React render. The hook itself is a
59
+ * thin wrapper around it (verified via on-device manual flow in
60
+ * the v0.12 verification checklist).
61
+ */
62
+ Object.defineProperty(exports, "__esModule", { value: true });
63
+ exports._computeDriftStateForTests = _computeDriftStateForTests;
64
+ exports.useOrientationDrift = useOrientationDrift;
65
+ const react_1 = require("react");
66
+ const useDeviceOrientation_1 = require("./useDeviceOrientation");
67
+ const INITIAL_STATE = {
68
+ captureOrientation: undefined,
69
+ drifted: false,
70
+ };
71
+ /**
72
+ * Pure state-transition function for the drift detector. Exported
73
+ * with a `_` prefix to signal "internal — not part of the public
74
+ * API." Jest uses this directly so tests don't need a React
75
+ * renderer (the lib's jest config is pure-data / no RN preset).
76
+ *
77
+ * Given the previous state + the current `active` flag + the
78
+ * current device orientation, returns the new state. Idempotent
79
+ * when nothing changed (returns the same object reference) so
80
+ * downstream `useState(setState)` calls become no-ops.
81
+ */
82
+ function _computeDriftStateForTests(prev, active, currentOrientation) {
83
+ if (!active) {
84
+ // active is false (or just transitioned to false). Clear the
85
+ // snapshot + drift flag. Idempotent when already cleared.
86
+ if (prev.captureOrientation === undefined && !prev.drifted) {
87
+ return prev;
88
+ }
89
+ return INITIAL_STATE;
90
+ }
91
+ // active is true.
92
+ if (prev.captureOrientation === undefined) {
93
+ // false → true transition. Snapshot the current orientation.
94
+ // drifted starts false because, by definition, the current
95
+ // orientation matches itself.
96
+ return { captureOrientation: currentOrientation, drifted: false };
97
+ }
98
+ // active is steady true. Check for drift. Latching: once
99
+ // drifted is true, never set it back to false until active
100
+ // flips (handled above).
101
+ if (!prev.drifted && currentOrientation !== prev.captureOrientation) {
102
+ return { captureOrientation: prev.captureOrientation, drifted: true };
103
+ }
104
+ // No transition + no new drift. Return prev to avoid an
105
+ // unnecessary state update + re-render.
106
+ return prev;
107
+ }
108
+ function useOrientationDrift(active) {
109
+ const currentOrientation = (0, useDeviceOrientation_1.useDeviceOrientation)();
110
+ const [state, setState] = (0, react_1.useState)(INITIAL_STATE);
111
+ (0, react_1.useEffect)(() => {
112
+ setState((prev) => _computeDriftStateForTests(prev, active, currentOrientation));
113
+ }, [active, currentOrientation]);
114
+ return {
115
+ drifted: state.drifted,
116
+ captureOrientation: state.captureOrientation,
117
+ currentOrientation,
118
+ };
119
+ }
120
+ //# sourceMappingURL=useOrientationDrift.js.map
package/dist/index.d.ts CHANGED
@@ -61,6 +61,11 @@ export { useCapture } from './camera/useCapture';
61
61
  export type { TakePhotoCallOptions } from './camera/useCapture';
62
62
  export { useVideoCapture } from './camera/useVideoCapture';
63
63
  export { useDeviceOrientation } from './camera/useDeviceOrientation';
64
+ export type { DeviceOrientation } from './camera/useDeviceOrientation';
65
+ export { useOrientationDrift } from './camera/useOrientationDrift';
66
+ export type { UseOrientationDriftReturn } from './camera/useOrientationDrift';
67
+ export { OrientationDriftModal } from './camera/OrientationDriftModal';
68
+ export type { OrientationDriftModalProps } from './camera/OrientationDriftModal';
64
69
  export { IncrementalOutcome, incrementalStitcherIsAvailable, subscribeIncrementalState, getIncrementalNativeModule, cleanupOldKeyframes, } from './stitching/incremental';
65
70
  export type { IncrementalState, AcceptedKeyframe } from './stitching/incremental';
66
71
  export { useIncrementalStitcher } from './stitching/useIncrementalStitcher';
package/dist/index.js CHANGED
@@ -22,7 +22,7 @@
22
22
  * adds RetaiLens-specific features on top.
23
23
  */
24
24
  Object.defineProperty(exports, "__esModule", { value: true });
25
- exports.stitchVideo = exports.useStitcherWorklet = exports.useFrameProcessorDriver = exports.useFrameStream = exports.useThrottledFrameProcessor = exports.useFrameProcessor = exports.useKeyframeStream = exports.useIncrementalStitcher = exports.cleanupOldKeyframes = exports.getIncrementalNativeModule = exports.subscribeIncrementalState = exports.incrementalStitcherIsAvailable = exports.IncrementalOutcome = exports.useDeviceOrientation = exports.useVideoCapture = exports.useCapture = exports.ViewportCropOverlay = exports.hybridSettingsToNativeConfig = exports.slitscanSettingsToNativeConfig = exports.panoramaSettingsToNativeConfig = exports.DEFAULT_HYBRID_SETTINGS = exports.DEFAULT_SLITSCAN_SETTINGS = exports.DEFAULT_FLOW_GATE_SETTINGS = exports.DEFAULT_PANORAMA_SETTINGS = exports.PanoramaSettingsModal = exports.PanoramaGuidance = exports.PanoramaBandOverlay = exports.IncrementalPanGuide = exports.CaptureThumbnailStrip = exports.useStitchStatsToast = exports.CaptureStitchStatsToast = exports.CaptureOrientationPill = exports.CaptureKeyframePill = exports.CaptureMemoryPill = exports.CaptureDebugOverlay = exports.CaptureStatusOverlay = exports.CapturePreview = exports.CaptureControlsBar = exports.CaptureHeader = exports.CameraView = exports.ARCameraView = exports.useIMUTranslationGate = exports.ARTrackingState = exports.useARSession = exports.CameraError = exports.Camera = void 0;
25
+ exports.stitchVideo = exports.useStitcherWorklet = exports.useFrameProcessorDriver = exports.useFrameStream = exports.useThrottledFrameProcessor = exports.useFrameProcessor = exports.useKeyframeStream = exports.useIncrementalStitcher = exports.cleanupOldKeyframes = exports.getIncrementalNativeModule = exports.subscribeIncrementalState = exports.incrementalStitcherIsAvailable = exports.IncrementalOutcome = exports.OrientationDriftModal = exports.useOrientationDrift = exports.useDeviceOrientation = exports.useVideoCapture = exports.useCapture = exports.ViewportCropOverlay = exports.hybridSettingsToNativeConfig = exports.slitscanSettingsToNativeConfig = exports.panoramaSettingsToNativeConfig = exports.DEFAULT_HYBRID_SETTINGS = exports.DEFAULT_SLITSCAN_SETTINGS = exports.DEFAULT_FLOW_GATE_SETTINGS = exports.DEFAULT_PANORAMA_SETTINGS = exports.PanoramaSettingsModal = exports.PanoramaGuidance = exports.PanoramaBandOverlay = exports.IncrementalPanGuide = exports.CaptureThumbnailStrip = exports.useStitchStatsToast = exports.CaptureStitchStatsToast = exports.CaptureOrientationPill = exports.CaptureKeyframePill = exports.CaptureMemoryPill = exports.CaptureDebugOverlay = exports.CaptureStatusOverlay = exports.CapturePreview = exports.CaptureControlsBar = exports.CaptureHeader = exports.CameraView = exports.ARCameraView = exports.useIMUTranslationGate = exports.ARTrackingState = exports.useARSession = exports.CameraError = exports.Camera = void 0;
26
26
  // ─────────────────────────────────────────────────────────────────────
27
27
  // Layer 1 — the high-level <Camera> component
28
28
  // ─────────────────────────────────────────────────────────────────────
@@ -126,6 +126,17 @@ var useVideoCapture_1 = require("./camera/useVideoCapture");
126
126
  Object.defineProperty(exports, "useVideoCapture", { enumerable: true, get: function () { return useVideoCapture_1.useVideoCapture; } });
127
127
  var useDeviceOrientation_1 = require("./camera/useDeviceOrientation");
128
128
  Object.defineProperty(exports, "useDeviceOrientation", { enumerable: true, get: function () { return useDeviceOrientation_1.useDeviceOrientation; } });
129
+ // v0.12.0 — orientation-aware Camera (R2-lite). `useOrientationDrift`
130
+ // snapshots the device orientation at capture start and latches a
131
+ // `drifted` flag if the user rotates mid-capture. Pairs with
132
+ // `OrientationDriftModal` for the auto-abandon UX flow. The
133
+ // flagship `<Camera>` component wires both internally (PR-2);
134
+ // Layer-2 hosts using `CameraView` directly can compose the pair
135
+ // manually (see the modal's docstring for the integration pattern).
136
+ var useOrientationDrift_1 = require("./camera/useOrientationDrift");
137
+ Object.defineProperty(exports, "useOrientationDrift", { enumerable: true, get: function () { return useOrientationDrift_1.useOrientationDrift; } });
138
+ var OrientationDriftModal_1 = require("./camera/OrientationDriftModal");
139
+ Object.defineProperty(exports, "OrientationDriftModal", { enumerable: true, get: function () { return OrientationDriftModal_1.OrientationDriftModal; } });
129
140
  // ── Incremental stitching engine ──────────────────────────────────────
130
141
  // JS bindings around the native `IncrementalStitcher` module. Use
131
142
  // these when you need finer control than <Camera>'s built-in
@@ -127,9 +127,11 @@ export interface IncrementalState {
127
127
  * at the FIRST-FRAME determination thereafter.
128
128
  *
129
129
  * **This is the single source of truth for orientation across
130
- * the SDK + host.** JS-side hooks (e.g. `useDeviceOrientation`,
131
- * `useWindowDimensions`) are unreliable when iOS interface-
132
- * orientation lock is on; pose-derived detection is. UI
130
+ * the SDK + host.** Pose-derived detection is preferred over
131
+ * JS-side hooks because it works identically regardless of host
132
+ * configuration `useWindowDimensions` reports JS-portrait when
133
+ * the host is portrait-locked (even with the device in landscape),
134
+ * while pose data reflects what the camera actually saw. UI
133
135
  * components that need to know orientation (band overlay, dim
134
136
  * bars, pan guide) MUST consume `state.isLandscape` rather
135
137
  * than re-detecting.
@@ -99,9 +99,15 @@ public final class RNSARSessionBridge: NSObject {
99
99
  ) {
100
100
  let path = (options["path"] as? String) ?? ""
101
101
  let quality = (options["quality"] as? Int) ?? 90
102
+ // v0.12.0 — host passes the actual device orientation so
103
+ // the saved JPEG matches the user's view. Defaults to
104
+ // "portrait" if absent, preserving pre-v0.12 behavior for
105
+ // any caller that hasn't been updated.
106
+ let orientation = (options["orientation"] as? String) ?? "portrait"
102
107
  RNSARSession.shared.takePhoto(
103
108
  toPath: path,
104
- quality: quality
109
+ quality: quality,
110
+ orientation: orientation
105
111
  ) { result, error in
106
112
  if let error = error {
107
113
  rejecter("ar-photo-failed", error.localizedDescription, error)
@@ -100,9 +100,10 @@ typedef NS_ENUM(NSInteger, RLISFrameOutcome) {
100
100
  /// JS side reads this from `IncrementalState.isLandscape` to drive
101
101
  /// orientation-aware UI (band overlay, dim bars). This is the
102
102
  /// single source of truth for orientation across the SDK + host —
103
- /// the V12.6 fix established that JS-side orientation hooks are
104
- /// unreliable under iOS interface-orientation lock; pose detection
105
- /// is.
103
+ /// pose-derived detection (V12.6) is preferred because it works
104
+ /// regardless of host orientation config (portrait-locked hosts
105
+ /// suppress framebuffer rotation, so `useWindowDimensions` lies
106
+ /// about the physical hold; pose data doesn't).
106
107
  @property (nonatomic, readonly) BOOL isLandscape;
107
108
 
108
109
  /// V12.14.9 — running max paint position along the pan axis, in
@@ -305,13 +305,15 @@ constexpr double kRansacReprojThresh = 5.0;
305
305
  _frameRotationDegrees = frameRotationDegrees;
306
306
  // V12.6 Step C: _isLandscape is no longer derived from the
307
307
  // JS-passed frameRotationDegrees. V12.5 telemetry proved
308
- // JS was sending the wrong value when iOS orientation-lock
309
- // suppressed the rotation event (always reported portrait
310
- // even in landscape). We now detect at first-frame init
311
- // from R_panToCam directly see the cylindricalWarp's
312
- // first-frame branch. Default false here is just a safe
313
- // initialiser; it WILL be overwritten before any warping
314
- // happens.
308
+ // JS was sending the wrong value under portrait-locked
309
+ // hosts (the lock suppresses RN's rotation event so
310
+ // `useWindowDimensions` always reported portrait even in
311
+ // landscape). We now detect at first-frame init from
312
+ // R_panToCam directly see the cylindricalWarp's
313
+ // first-frame branch. Pose-derived detection works for
314
+ // both portrait-locked and non-locked R2-lite hosts.
315
+ // Default false here is just a safe initialiser; it WILL
316
+ // be overwritten before any warping happens.
315
317
  _isLandscape = NO;
316
318
  // Default compose dims preserve the 4:3 sensor aspect
317
319
  // (1920x1440 → 960x720 at scale 0.5). Always landscape
@@ -813,6 +813,7 @@ public final class RNSARSession: NSObject, ARSessionDelegate {
813
813
  @objc public func takePhoto(
814
814
  toPath rawPath: String,
815
815
  quality: Int,
816
+ orientation: String,
816
817
  completion: @escaping ([String: Any]?, NSError?) -> Void
817
818
  ) {
818
819
  let resolvedPath: String
@@ -835,14 +836,52 @@ public final class RNSARSession: NSObject, ARSessionDelegate {
835
836
  }
836
837
  let pixelBuffer = frame.capturedImage
837
838
 
838
- // ARKit's capturedImage is in landscape sensor orientation
839
- // regardless of how the device is held. Rotate to portrait
840
- // (the way the user is holding the phone for shelf audits)
841
- // by applying a 90° clockwise CIImage orientation. Without
842
- // this, photos appear sideways in any consumer that doesn't
843
- // honour EXIF (RN's <Image>, the OpenCV stitcher).
839
+ // v0.12.0 Pre-v0.12 this method hardcoded `.right` (90° CW)
840
+ // to rotate-to-portrait, assuming the user always held the
841
+ // phone in portrait. Under R2-lite the device can be in
842
+ // any orientation, so we pick the CIImage orientation per
843
+ // the JS-supplied `orientation` arg (from
844
+ // `useDeviceOrientation()`).
845
+ //
846
+ // Empirical mapping (on-device test 2026-05-28):
847
+ // portrait → .right (90° CW — preserved from pre-v0.12)
848
+ // landscape-left → .up (sensor matches device tilt; no rotation)
849
+ // landscape-right → .down (180° — sensor opposite of device tilt)
850
+ // portrait-upside-down → .left (90° CCW)
851
+ //
852
+ // The landscape mapping (landscape-left → .up) was determined
853
+ // empirically and is the opposite of what Apple's ARKit
854
+ // pixel-buffer-orientation docs would imply. Likely because
855
+ // `useDeviceOrientation()` reports `landscape-left` via the
856
+ // `UIDeviceOrientation` convention (home indicator on user-
857
+ // right) while iOS's sensor-native orientation matches that
858
+ // tilt direction directly. Without this fix, AR-mode single
859
+ // photos in landscape come out upside-down.
860
+ // v0.12.0 — Pre-v0.12 this method hardcoded `.right` (90° CW)
861
+ // to rotate-to-portrait, assuming the user always held the
862
+ // phone in portrait. Under R2-lite the device can be in
863
+ // any orientation, so we pick the CIImage orientation per
864
+ // the JS-supplied `orientation` arg (from
865
+ // `useDeviceOrientation()`).
866
+ //
867
+ // Empirical mapping (on-device test 2026-05-28):
868
+ // portrait → .right (90° CW — preserved from pre-v0.12)
869
+ // landscape-left → .up (sensor matches device tilt; no rotation)
870
+ // landscape-right → .down (180° — sensor opposite of device tilt)
871
+ // portrait-upside-down → .left (90° CCW)
872
+ //
873
+ // The landscape mapping (landscape-left → .up) was determined
874
+ // empirically; the user reported AR landscape photos came out
875
+ // upside-down with .down and correctly upright with .up.
876
+ let exifOrientation: CGImagePropertyOrientation
877
+ switch orientation {
878
+ case "landscape-left": exifOrientation = .up
879
+ case "landscape-right": exifOrientation = .down
880
+ case "portrait-upside-down": exifOrientation = .left
881
+ default: exifOrientation = .right // portrait + unknown
882
+ }
844
883
  let ciImage = CIImage(cvPixelBuffer: pixelBuffer)
845
- .oriented(.right)
884
+ .oriented(exifOrientation)
846
885
  let context = CIContext(options: nil)
847
886
  guard let cgImage = context.createCGImage(
848
887
  ciImage,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-image-stitcher",
3
- "version": "0.11.1",
3
+ "version": "0.13.0",
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",
@@ -84,7 +84,23 @@ export interface ARCameraViewHandle {
84
84
  * isMirrored, isRawPhoto }`). Native generates a temp path —
85
85
  * caller does NOT need to construct one.
86
86
  */
87
- takePhoto: (options?: { quality?: number }) => Promise<{
87
+ takePhoto: (options?: {
88
+ quality?: number;
89
+ /**
90
+ * v0.12.0 — device orientation at capture time, used to bake
91
+ * correct rotation into the saved JPEG. Pass the value from
92
+ * `useDeviceOrientation()`. Defaults to `'portrait'` on the
93
+ * native side if omitted (preserves pre-v0.12 behavior).
94
+ * Without this, AR-mode photos taken in landscape come out
95
+ * sideways because the native side previously hardcoded the
96
+ * rotate-to-portrait assumption.
97
+ */
98
+ orientation?:
99
+ | 'portrait'
100
+ | 'portrait-upside-down'
101
+ | 'landscape-left'
102
+ | 'landscape-right';
103
+ }) => Promise<{
88
104
  path: string;
89
105
  width: number;
90
106
  height: number;
@@ -149,6 +165,7 @@ export const ARCameraView = forwardRef<ARCameraViewHandle, ARCameraViewProps>(
149
165
  return native.takePhoto({
150
166
  path: '',
151
167
  quality: options.quality ?? 90,
168
+ orientation: options.orientation ?? 'portrait',
152
169
  });
153
170
  },
154
171
  startRecording: (options) => {