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,169 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Unit tests for `useOrientationDrift` — exercises the pure
4
+ * state-transition function `_computeDriftStateForTests` directly.
5
+ *
6
+ * Why not test the hook end-to-end via render: the lib's jest
7
+ * config is `preset: 'ts-jest'` + `testEnvironment: 'node'` — no
8
+ * React Native preset, no `@testing-library/react-native`. See the
9
+ * jest.config.js header comment: "If we ever add component-render
10
+ * tests we'd flip to the RN preset then." The component-render
11
+ * tests for `<OrientationDriftModal>`, `<PanoramaBandOverlay>`,
12
+ * `<ViewportCropOverlay>`, and `<Camera>` composition (all called
13
+ * out in the v0.12 plan) will all need that flip. Setting it up
14
+ * is grouped in Phase 5 of the plan (Tests) rather than scattered
15
+ * across each PR. For PR-1, the pure state-transition function
16
+ * carries the full behavioural contract — same approach
17
+ * `useThrottledFrameProcessor.test.ts` uses for its throttle gate.
18
+ *
19
+ * The 5 cases below cover the full state machine per the plan
20
+ * (lines 119, 277):
21
+ *
22
+ * (a) no change → not drifted
23
+ * (b) orientation changes during active=true → drifted
24
+ * (c) drift state survives further changes (latching)
25
+ * (d) inactive → captureOrientation undefined
26
+ * (e) active resets snapshot (false → true → false → true cycle)
27
+ */
28
+
29
+ // Mock `react-native-sensors` BEFORE importing the SUT. The hook
30
+ // itself transitively pulls in `useDeviceOrientation` which imports
31
+ // `accelerometer` from `react-native-sensors` — an ES module that
32
+ // jest can't parse without the RN preset (which jest.config.js
33
+ // intentionally avoids; see config header comment). We're only
34
+ // testing the pure transition function below, but TS imports are
35
+ // transitive so we still need to silence the chain.
36
+ jest.mock('react-native-sensors', () => ({
37
+ accelerometer: { subscribe: jest.fn(() => ({ unsubscribe: jest.fn() })) },
38
+ setUpdateIntervalForType: jest.fn(),
39
+ SensorTypes: { accelerometer: 'accelerometer' },
40
+ }));
41
+
42
+ // eslint-disable-next-line import/first
43
+ import { _computeDriftStateForTests } from '../useOrientationDrift';
44
+
45
+ const INITIAL = { captureOrientation: undefined, drifted: false };
46
+
47
+ describe('_computeDriftStateForTests (useOrientationDrift core logic)', () => {
48
+ describe('(a) no change → not drifted', () => {
49
+ it('stays in initial state when active is false from the start', () => {
50
+ const next = _computeDriftStateForTests(INITIAL, false, 'portrait');
51
+ expect(next).toEqual({ captureOrientation: undefined, drifted: false });
52
+ });
53
+
54
+ it('snapshots orientation when active flips true, drifted starts false', () => {
55
+ const next = _computeDriftStateForTests(INITIAL, true, 'portrait');
56
+ expect(next).toEqual({ captureOrientation: 'portrait', drifted: false });
57
+ });
58
+
59
+ it('stays clean when active=true and orientation does not change', () => {
60
+ const after1 = _computeDriftStateForTests(INITIAL, true, 'portrait');
61
+ const after2 = _computeDriftStateForTests(after1, true, 'portrait');
62
+ const after3 = _computeDriftStateForTests(after2, true, 'portrait');
63
+ expect(after3).toEqual({ captureOrientation: 'portrait', drifted: false });
64
+ // Reference equality: once steady, returns the prev ref so
65
+ // React's setState becomes a no-op (no re-render).
66
+ expect(after2).toBe(after1);
67
+ expect(after3).toBe(after2);
68
+ });
69
+ });
70
+
71
+ describe('(b) orientation changes during active=true → drifted', () => {
72
+ it('latches drifted=true when orientation changes mid-active', () => {
73
+ const after1 = _computeDriftStateForTests(INITIAL, true, 'portrait');
74
+ const after2 = _computeDriftStateForTests(after1, true, 'landscape-left');
75
+ expect(after2).toEqual({ captureOrientation: 'portrait', drifted: true });
76
+ });
77
+
78
+ it('captures the ORIGINAL orientation in captureOrientation, not the new one', () => {
79
+ const after1 = _computeDriftStateForTests(INITIAL, true, 'portrait');
80
+ const after2 = _computeDriftStateForTests(after1, true, 'landscape-right');
81
+ // captureOrientation MUST remain the snapshot (portrait), not
82
+ // the current rotation — that's how the drift modal copy
83
+ // ("captured in PORTRAIT, now LANDSCAPE-RIGHT") works.
84
+ expect(after2.captureOrientation).toBe('portrait');
85
+ });
86
+
87
+ it('detects drift to any of the 3 other orientations', () => {
88
+ const cases: Array<['portrait', 'portrait-upside-down' | 'landscape-left' | 'landscape-right']> = [
89
+ ['portrait', 'portrait-upside-down'],
90
+ ['portrait', 'landscape-left'],
91
+ ['portrait', 'landscape-right'],
92
+ ];
93
+ for (const [captured, drifted] of cases) {
94
+ const after1 = _computeDriftStateForTests(INITIAL, true, captured);
95
+ const after2 = _computeDriftStateForTests(after1, true, drifted);
96
+ expect(after2.drifted).toBe(true);
97
+ }
98
+ });
99
+ });
100
+
101
+ describe('(c) drift state survives further changes (latching)', () => {
102
+ it('stays drifted even if the user rotates back to the captured orientation', () => {
103
+ // User rotates portrait → landscape (drift triggers) → portrait
104
+ // (back to original). The flag MUST stay latched. Rationale:
105
+ // the engine docstring says cross-mode capture is "best-effort,
106
+ // not supported" — a brief rotation pollutes the buffer even
107
+ // if the user rotates back, so the safe action is decisive
108
+ // abandonment regardless of post-detection orientation.
109
+ const after1 = _computeDriftStateForTests(INITIAL, true, 'portrait');
110
+ const after2 = _computeDriftStateForTests(after1, true, 'landscape-left');
111
+ const after3 = _computeDriftStateForTests(after2, true, 'portrait');
112
+ expect(after3).toEqual({ captureOrientation: 'portrait', drifted: true });
113
+ });
114
+
115
+ it('stays drifted across multiple subsequent orientation changes', () => {
116
+ const after1 = _computeDriftStateForTests(INITIAL, true, 'portrait');
117
+ const after2 = _computeDriftStateForTests(after1, true, 'landscape-left');
118
+ const after3 = _computeDriftStateForTests(after2, true, 'landscape-right');
119
+ const after4 = _computeDriftStateForTests(after3, true, 'portrait-upside-down');
120
+ expect(after4.drifted).toBe(true);
121
+ expect(after4.captureOrientation).toBe('portrait');
122
+ });
123
+ });
124
+
125
+ describe('(d) inactive → captureOrientation undefined', () => {
126
+ it('clears the snapshot when active flips back to false', () => {
127
+ const after1 = _computeDriftStateForTests(INITIAL, true, 'portrait');
128
+ const after2 = _computeDriftStateForTests(after1, false, 'portrait');
129
+ expect(after2).toEqual({ captureOrientation: undefined, drifted: false });
130
+ });
131
+
132
+ it('clears the drift flag when active flips back to false', () => {
133
+ const after1 = _computeDriftStateForTests(INITIAL, true, 'portrait');
134
+ const after2 = _computeDriftStateForTests(after1, true, 'landscape-left');
135
+ expect(after2.drifted).toBe(true);
136
+ const after3 = _computeDriftStateForTests(after2, false, 'landscape-left');
137
+ expect(after3).toEqual({ captureOrientation: undefined, drifted: false });
138
+ });
139
+
140
+ it('is idempotent — no state change when inactive and already clear', () => {
141
+ const after1 = _computeDriftStateForTests(INITIAL, false, 'portrait');
142
+ const after2 = _computeDriftStateForTests(after1, false, 'landscape-left');
143
+ // Same ref → setState becomes a no-op.
144
+ expect(after2).toBe(after1);
145
+ });
146
+ });
147
+
148
+ describe('(e) active resets snapshot', () => {
149
+ it('re-snapshots on a fresh active cycle (false → true → false → true)', () => {
150
+ // Cycle 1: capture in portrait, drift.
151
+ const c1a = _computeDriftStateForTests(INITIAL, true, 'portrait');
152
+ const c1b = _computeDriftStateForTests(c1a, true, 'landscape-left');
153
+ expect(c1b).toEqual({ captureOrientation: 'portrait', drifted: true });
154
+
155
+ // Stop the capture.
156
+ const cleared = _computeDriftStateForTests(c1b, false, 'landscape-left');
157
+ expect(cleared).toEqual({ captureOrientation: undefined, drifted: false });
158
+
159
+ // Cycle 2: re-capture, now in landscape-left. Snapshot
160
+ // should be landscape-left, NOT carry over the old portrait.
161
+ const c2a = _computeDriftStateForTests(cleared, true, 'landscape-left');
162
+ expect(c2a).toEqual({ captureOrientation: 'landscape-left', drifted: false });
163
+
164
+ // And staying in landscape-left should not drift.
165
+ const c2b = _computeDriftStateForTests(c2a, true, 'landscape-left');
166
+ expect(c2b.drifted).toBe(false);
167
+ });
168
+ });
169
+ });
@@ -2,15 +2,24 @@
2
2
  /**
3
3
  * useDeviceOrientation — physical device orientation hook.
4
4
  *
5
- * The host app is portrait-locked at the iOS app level (so the
6
- * camera preview, header, controls, and thumbnails stay in their
7
- * portrait positions even when the user holds the phone sideways
8
- * for a vertical pan). But text overlays — the REC banner, the
9
- * pan-speed pill, the live frame strip — need to follow the
10
- * physical device orientation so they stay readable in the user's
11
- * hands. RN's `useWindowDimensions` can't help with this when
12
- * the app is orientation-locked: window dimensions don't change
13
- * when only the device rotates.
5
+ * Hooks into the accelerometer to report the device's physical
6
+ * orientation as a 4-way `DeviceOrientation` value. Works
7
+ * identically regardless of host configuration:
8
+ *
9
+ * - Portrait-locked host (Info.plist UISupportedInterfaceOrientations
10
+ * restricted to Portrait): RN's `useWindowDimensions` returns
11
+ * portrait dims regardless of physical tilt. This hook reads
12
+ * the sensor directly, so text overlays (REC banner, pan-speed
13
+ * pill, live frame strip) can still follow the user's hold.
14
+ * - Non-locked host (Info.plist supports all 4): the OS rotates
15
+ * the framebuffer with the device; `useWindowDimensions` reflects
16
+ * the rotated JS layout. This hook still reports physical tilt
17
+ * — useful in combination with window dims to detect whether
18
+ * the screen rotated to match the device (`<Camera>`'s v0.12
19
+ * `homeIndicatorEdge` logic uses both signals together).
20
+ *
21
+ * Either way the sensor is the single source of truth for "where
22
+ * the user's hands actually are."
14
23
  *
15
24
  * 2026-05-21 (v0.2 — Expo modules removal) — rewritten back onto
16
25
  * `react-native-sensors` accelerometer. `expo-sensors`'
@@ -0,0 +1,172 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * useOrientationDrift — detects mid-capture device rotation.
4
+ *
5
+ * Pairs with `useDeviceOrientation()` to surface the case where the
6
+ * user rotates the device *during* an active capture. The
7
+ * incremental stitching engine supports both portrait (Mode B,
8
+ * horizontal pan) and landscape (Mode A, vertical pan) capture
9
+ * modes as first-class — but mixing them mid-capture produces
10
+ * malformed output ("cross-mode capture is best-effort," per
11
+ * `incremental.ts:373-403`). Hosts that want to protect against
12
+ * this use this hook + `OrientationDriftModal` together: the
13
+ * `<Camera>` flagship component auto-abandons capture the instant
14
+ * `drifted === true` (PR-2 wiring); the modal surfaces an
15
+ * explanatory popup to the user.
16
+ *
17
+ * ## API contract
18
+ *
19
+ * Pass `active` true while a capture is in flight, false otherwise.
20
+ * Returns:
21
+ *
22
+ * - `captureOrientation` — the orientation snapshotted at the
23
+ * moment `active` transitioned false → true. `undefined` when
24
+ * `active` is false.
25
+ * - `currentOrientation` — live orientation from
26
+ * `useDeviceOrientation()`. Always defined (defaults to
27
+ * `'portrait'` until the accelerometer's first sample).
28
+ * - `drifted` — `true` IFF `active` is currently true AND
29
+ * `currentOrientation !== captureOrientation` at some point
30
+ * since the snapshot. **Latching** — once true, stays true
31
+ * until `active` flips back to false. This is intentional:
32
+ * after detection, callers should auto-abandon the capture
33
+ * (engine `stop()`); allowing the flag to clear before then
34
+ * would mask the drift if the user rotated back to the
35
+ * original orientation between the detection tick and the
36
+ * callers' abandonment effect.
37
+ *
38
+ * ## Semantics by transition
39
+ *
40
+ * - `active` false → true: snapshot `currentOrientation`;
41
+ * reset `drifted` to false.
42
+ * - `active` true (steady): if `currentOrientation !==
43
+ * captureOrientation` at any point, latch `drifted = true`.
44
+ * - `active` true → false: clear snapshot; reset `drifted`.
45
+ *
46
+ * ## Why a separate hook (rather than inlining in `<Camera>`)
47
+ *
48
+ * Hosts using the Layer-2 building blocks (`CameraView` directly,
49
+ * custom capture UX) can reuse this hook without mounting the
50
+ * full `<Camera>` flagship. Same composition pattern as
51
+ * `useIMUTranslationGate` and `useKeyframeStream`.
52
+ *
53
+ * ## Testing
54
+ *
55
+ * The pure state-transition function `_computeDriftStateForTests`
56
+ * is exported separately so jest can exercise all 5 transition
57
+ * cases without booting a React render. The hook itself is a
58
+ * thin wrapper around it (verified via on-device manual flow in
59
+ * the v0.12 verification checklist).
60
+ */
61
+
62
+ import { useEffect, useState } from 'react';
63
+
64
+ import {
65
+ useDeviceOrientation,
66
+ type DeviceOrientation,
67
+ } from './useDeviceOrientation';
68
+
69
+
70
+ export interface UseOrientationDriftReturn {
71
+ /**
72
+ * `true` IFF a capture is active and the device has rotated since
73
+ * the snapshot taken at capture start. Latching: once true, stays
74
+ * true until `active` flips false.
75
+ */
76
+ drifted: boolean;
77
+
78
+ /**
79
+ * Snapshot of `currentOrientation` at the moment `active`
80
+ * transitioned false → true. `undefined` when `active` is false.
81
+ */
82
+ captureOrientation: DeviceOrientation | undefined;
83
+
84
+ /**
85
+ * Live device orientation from `useDeviceOrientation()`. Always
86
+ * defined. Exposed so callers (e.g. the drift modal) can show
87
+ * "captured in PORTRAIT, now LANDSCAPE-LEFT" copy without
88
+ * mounting `useDeviceOrientation()` themselves.
89
+ */
90
+ currentOrientation: DeviceOrientation;
91
+ }
92
+
93
+
94
+ /**
95
+ * Internal state of the drift detector. Two scalar pieces: the
96
+ * snapshotted capture orientation (undefined when inactive) + the
97
+ * latched drift flag.
98
+ */
99
+ interface DriftState {
100
+ captureOrientation: DeviceOrientation | undefined;
101
+ drifted: boolean;
102
+ }
103
+
104
+
105
+ const INITIAL_STATE: DriftState = {
106
+ captureOrientation: undefined,
107
+ drifted: false,
108
+ };
109
+
110
+
111
+ /**
112
+ * Pure state-transition function for the drift detector. Exported
113
+ * with a `_` prefix to signal "internal — not part of the public
114
+ * API." Jest uses this directly so tests don't need a React
115
+ * renderer (the lib's jest config is pure-data / no RN preset).
116
+ *
117
+ * Given the previous state + the current `active` flag + the
118
+ * current device orientation, returns the new state. Idempotent
119
+ * when nothing changed (returns the same object reference) so
120
+ * downstream `useState(setState)` calls become no-ops.
121
+ */
122
+ export function _computeDriftStateForTests(
123
+ prev: DriftState,
124
+ active: boolean,
125
+ currentOrientation: DeviceOrientation,
126
+ ): DriftState {
127
+ if (!active) {
128
+ // active is false (or just transitioned to false). Clear the
129
+ // snapshot + drift flag. Idempotent when already cleared.
130
+ if (prev.captureOrientation === undefined && !prev.drifted) {
131
+ return prev;
132
+ }
133
+ return INITIAL_STATE;
134
+ }
135
+
136
+ // active is true.
137
+ if (prev.captureOrientation === undefined) {
138
+ // false → true transition. Snapshot the current orientation.
139
+ // drifted starts false because, by definition, the current
140
+ // orientation matches itself.
141
+ return { captureOrientation: currentOrientation, drifted: false };
142
+ }
143
+
144
+ // active is steady true. Check for drift. Latching: once
145
+ // drifted is true, never set it back to false until active
146
+ // flips (handled above).
147
+ if (!prev.drifted && currentOrientation !== prev.captureOrientation) {
148
+ return { captureOrientation: prev.captureOrientation, drifted: true };
149
+ }
150
+
151
+ // No transition + no new drift. Return prev to avoid an
152
+ // unnecessary state update + re-render.
153
+ return prev;
154
+ }
155
+
156
+
157
+ export function useOrientationDrift(
158
+ active: boolean,
159
+ ): UseOrientationDriftReturn {
160
+ const currentOrientation = useDeviceOrientation();
161
+ const [state, setState] = useState<DriftState>(INITIAL_STATE);
162
+
163
+ useEffect(() => {
164
+ setState((prev) => _computeDriftStateForTests(prev, active, currentOrientation));
165
+ }, [active, currentOrientation]);
166
+
167
+ return {
168
+ drifted: state.drifted,
169
+ captureOrientation: state.captureOrientation,
170
+ currentOrientation,
171
+ };
172
+ }
package/src/index.ts CHANGED
@@ -162,6 +162,19 @@ export { useCapture } from './camera/useCapture';
162
162
  export type { TakePhotoCallOptions } from './camera/useCapture';
163
163
  export { useVideoCapture } from './camera/useVideoCapture';
164
164
  export { useDeviceOrientation } from './camera/useDeviceOrientation';
165
+ export type { DeviceOrientation } from './camera/useDeviceOrientation';
166
+
167
+ // v0.12.0 — orientation-aware Camera (R2-lite). `useOrientationDrift`
168
+ // snapshots the device orientation at capture start and latches a
169
+ // `drifted` flag if the user rotates mid-capture. Pairs with
170
+ // `OrientationDriftModal` for the auto-abandon UX flow. The
171
+ // flagship `<Camera>` component wires both internally (PR-2);
172
+ // Layer-2 hosts using `CameraView` directly can compose the pair
173
+ // manually (see the modal's docstring for the integration pattern).
174
+ export { useOrientationDrift } from './camera/useOrientationDrift';
175
+ export type { UseOrientationDriftReturn } from './camera/useOrientationDrift';
176
+ export { OrientationDriftModal } from './camera/OrientationDriftModal';
177
+ export type { OrientationDriftModalProps } from './camera/OrientationDriftModal';
165
178
 
166
179
  // ── Incremental stitching engine ──────────────────────────────────────
167
180
  // JS bindings around the native `IncrementalStitcher` module. Use
@@ -139,9 +139,11 @@ export interface IncrementalState {
139
139
  * at the FIRST-FRAME determination thereafter.
140
140
  *
141
141
  * **This is the single source of truth for orientation across
142
- * the SDK + host.** JS-side hooks (e.g. `useDeviceOrientation`,
143
- * `useWindowDimensions`) are unreliable when iOS interface-
144
- * orientation lock is on; pose-derived detection is. UI
142
+ * the SDK + host.** Pose-derived detection is preferred over
143
+ * JS-side hooks because it works identically regardless of host
144
+ * configuration `useWindowDimensions` reports JS-portrait when
145
+ * the host is portrait-locked (even with the device in landscape),
146
+ * while pose data reflects what the camera actually saw. UI
145
147
  * components that need to know orientation (band overlay, dim
146
148
  * bars, pan guide) MUST consume `state.isLandscape` rather
147
149
  * than re-detecting.