react-native-image-stitcher 0.11.1 → 0.12.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 +75 -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 +20 -0
  7. package/dist/camera/Camera.js +175 -6
  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 +280 -13
  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
@@ -87,6 +87,18 @@ export type BandCaptureOrientation =
87
87
  | 'landscape-right';
88
88
 
89
89
  export interface PanoramaBandOverlayProps {
90
+ /**
91
+ * v0.12.0 — `true` when the band should render as a vertical
92
+ * column in JS (anchor edge is JS-left or JS-right, i.e.
93
+ * non-locked host with device-landscape). `false` (default)
94
+ * renders the legacy horizontal strip — covers portrait-locked
95
+ * hosts in any device orientation AND non-locked hosts in
96
+ * portrait. The flagship `<Camera>` derives this from
97
+ * `useWindowDimensions()` + `useDeviceOrientation()` (see
98
+ * `homeIndicatorEdge` in `Camera.tsx`); Layer-2 hosts pass it
99
+ * directly.
100
+ */
101
+ vertical?: boolean;
90
102
  /**
91
103
  * Latest engine state. Pass `useIncrementalStitcher().state`.
92
104
  * Used for single-thumb fallback URI and fill-ratio when no
@@ -138,8 +150,12 @@ interface Layout {
138
150
  kind: LayoutKind;
139
151
  /** Outer container style — positioning + flexDirection. */
140
152
  band: ViewStyle;
141
- /** Direction used by both the outer band AND the scroll content. */
142
- flexDirection: 'row' | 'row-reverse';
153
+ /**
154
+ * Direction used by both the outer band AND the scroll content.
155
+ * row/row-reverse for horizontal bands; column/column-reverse for
156
+ * vertical bands (non-locked host in landscape, jsLandscape=true).
157
+ */
158
+ flexDirection: 'row' | 'row-reverse' | 'column' | 'column-reverse';
143
159
  /** Unicode arrow pointing along the user-perceived pan axis. */
144
160
  arrowGlyph: string;
145
161
  }
@@ -182,26 +198,53 @@ interface Layout {
182
198
  * reads as user-right-arrow (pointing along the horizontal pan
183
199
  * direction).
184
200
  */
185
- function layoutFor(orientation: BandCaptureOrientation): Layout {
201
+ function layoutFor(
202
+ orientation: BandCaptureOrientation,
203
+ vertical: boolean,
204
+ ): Layout {
186
205
  const commonInner: ViewStyle = {
187
206
  alignItems: 'center',
188
207
  paddingHorizontal: BAND_PADDING,
189
208
  paddingVertical: BAND_PADDING,
190
209
  backgroundColor: 'rgba(0, 0, 0, 0.55)',
191
210
  };
192
- // 2026-05-19repositioned tethered to the shutter (no longer
193
- // edge-pinned via absolute positioning). The parent stack in
194
- // Camera.tsx now puts this band in a vertical column immediately
195
- // above the shutter row. The SDK's orientation lock holds the UI
196
- // in portrait regardless of physical device rotation, so the band
197
- // is ALWAYS a horizontal strip in JS coordinates. In landscape
198
- // (physically held), the rendered strip visually appears as a
199
- // vertical column on the viewport-side of the shutter.
211
+ // v0.12.0band structural orientation tracks the host's
212
+ // `vertical` flag (which the host derives from JS layout
213
+ // orientation):
214
+ //
215
+ // vertical=false Horizontal strip in JS coords. Under
216
+ // portrait-lock + device-landscape this appears
217
+ // as a vertical column on user-right via the
218
+ // un-rotated framebuffer.
219
+ // vertical=true Vertical column in JS coords. Non-locked
220
+ // + device-landscape — band lives along the
221
+ // JS-side strip where the home indicator is.
200
222
  //
201
- // What still varies by physical orientation: the order in which
202
- // thumbnails should appear so newest is at the user-perceived
203
- // "leading edge" of the pan. That's the flexDirection (row vs
204
- // row-reverse) and the arrow glyph.
223
+ // What still varies by physical orientation regardless: the
224
+ // thumbnail flow direction so newest sits at the user-perceived
225
+ // pan-leading edge (flexDirection + arrowGlyph).
226
+ if (vertical) {
227
+ // Vertical band in JS coords (non-locked landscape). The OS
228
+ // rotated the framebuffer so user-top = JS-top, user-bottom =
229
+ // JS-bottom — same scroll direction regardless of whether the
230
+ // device is landscape-left or landscape-right. Latest grows
231
+ // toward user-bottom (= JS-bottom). flexDirection 'column'
232
+ // puts array[0]/oldest at JS-top.
233
+ return {
234
+ kind: 'landscape',
235
+ band: {
236
+ marginHorizontal: 8,
237
+ marginVertical: 16,
238
+ width: BAND_THICKNESS,
239
+ flexDirection: 'column',
240
+ ...commonInner,
241
+ },
242
+ flexDirection: 'column',
243
+ arrowGlyph: '↓',
244
+ };
245
+ }
246
+ // vertical=false branch: pre-v0.12 horizontal-strip behavior
247
+ // keyed on device-physical orientation for thumbnail direction.
205
248
  if (orientation === 'landscape-left') {
206
249
  // Phone rotated 90° CCW from portrait (home indicator on the
207
250
  // user's RIGHT). With UI orientation-locked to portrait:
@@ -268,6 +311,7 @@ export function PanoramaBandOverlay({
268
311
  state,
269
312
  frameUris,
270
313
  captureOrientation,
314
+ vertical = false,
271
315
  }: PanoramaBandOverlayProps): React.JSX.Element | null {
272
316
  // 2026-05-18 (Issue #3 fix) — orientation source priority:
273
317
  // 1. `captureOrientation` prop from the host (4-way; correct
@@ -280,8 +324,8 @@ export function PanoramaBandOverlay({
280
324
  captureOrientation
281
325
  ?? (state?.isLandscape ? 'landscape-left' : 'portrait');
282
326
  const layout = useMemo(
283
- () => layoutFor(resolvedOrientation),
284
- [resolvedOrientation],
327
+ () => layoutFor(resolvedOrientation, vertical),
328
+ [resolvedOrientation, vertical],
285
329
  );
286
330
 
287
331
  const scrollRef = useRef<ScrollView | null>(null);
@@ -299,24 +343,21 @@ export function PanoramaBandOverlay({
299
343
 
300
344
  const hasMultiThumb = cappedFrameUris.length > 0;
301
345
 
302
- // Auto-scroll on content-size change.
303
- //
304
- // 2026-05-18 (Issue #4 fix-b): the direction depends on flex
305
- // direction. In `row` (portrait, landscape-right) the LATEST
306
- // item is at JS-rightmost → scrollToEnd shows it. In
307
- // `row-reverse` (landscape-left) the latest is at JS-leftmost →
308
- // scrollTo({x: 0}) shows it. The earlier always-scrollToEnd
309
- // behaviour scrolled to OLDEST in row-reverse, which hid the
310
- // just-captured frame off-screen at user-bottom.
346
+ // Auto-scroll on content-size change. `*-reverse` puts latest at
347
+ // scroll origin (scrollTo {0,0}); normal `row`/`column` puts
348
+ // latest at scroll end (scrollToEnd).
349
+ const isReverse =
350
+ layout.flexDirection === 'row-reverse' ||
351
+ layout.flexDirection === 'column-reverse';
311
352
  const onContentSizeChange = useCallback(() => {
312
353
  const sv = scrollRef.current;
313
354
  if (!sv) return;
314
- if (layout.flexDirection === 'row-reverse') {
355
+ if (isReverse) {
315
356
  sv.scrollTo({ x: 0, y: 0, animated: false });
316
357
  } else {
317
358
  sv.scrollToEnd({ animated: false });
318
359
  }
319
- }, [layout.flexDirection]);
360
+ }, [isReverse]);
320
361
 
321
362
  // ── Single cumulative thumbnail (live-engine fallback) ──────────
322
363
  //
@@ -339,27 +380,70 @@ export function PanoramaBandOverlay({
339
380
  return Math.max(SINGLE_THUMB_INNER, SINGLE_THUMB_MAX_PAN_LEN * fillRatio);
340
381
  }, [fillRatio]);
341
382
 
342
- // V12.14.9 rotate the panorama image 90° in landscape mode so
343
- // the captured scene reads UPRIGHT to the user in landscape head-up
344
- // view. See original comment in the pre-V16 PanoramaBandOverlay for
345
- // the full reasoning. Portrait+horizontal-pan mode (the other
346
- // supported mode) doesn't need rotation.
383
+ // Image rotation transform for thumbnails. Captured frames are in
384
+ // user-perspective orientation (the capture pipeline rotates the
385
+ // sensor-native bytes via `outputOrientation="device"` + EXIF
386
+ // baking in `normaliseOrientation`). The thumbnail BOX is in
387
+ // JS coords. When JS coords are device-aligned (portrait-lock,
388
+ // i.e. vertical=false here) and the device is in landscape, the
389
+ // image content is rotated 90° from the box's axes → appears
390
+ // sideways without compensation. Apply a counter-rotation to
391
+ // line content up with the box's perceived "top".
347
392
  //
348
- // 2026-05-18 (Issue #3) derive from `resolvedOrientation` instead
349
- // of the deprecated 2-way `isLandscape`. In landscape-RIGHT we
350
- // rotate −90° so the captured scene still reads upright (the
351
- // opposite sense from landscape-LEFT).
393
+ // When vertical=true (non-locked + device-landscape; JS coords
394
+ // rotated with screen), the box IS user-aligned already. No
395
+ // rotation needed the image is already correctly oriented for
396
+ // direct display.
397
+ //
398
+ // V12.14.9 → v0.12.0 — extended from single-thumb (cumulative
399
+ // panorama image fallback) to the multi-thumb path too. Pre-
400
+ // v0.12 the multi-thumb keyframe thumbnails had no rotation
401
+ // transform, so they appeared sideways in portrait-locked
402
+ // landscape captures (the case the example app's batch-keyframe
403
+ // engine hits).
404
+ const thumbRotationTransform = useMemo<
405
+ Array<{ rotate: string }> | undefined
406
+ >(() => {
407
+ // Empirical observation (on-device test 2026-05-28): captured
408
+ // per-keyframe JPEGs ARE saved in sensor-native landscape (not
409
+ // user-perspective), despite the cumulative panorama getting
410
+ // device-orientation rotation via finalize(). So:
411
+ //
412
+ // jsPortrait box + landscape device: box is device-aligned;
413
+ // image's "up" is at file-right (sensor convention). Rotate
414
+ // 90° CW (landscape-left) / 90° CCW (landscape-right) to
415
+ // align image up with box up.
416
+ // jsLandscape box + landscape device: box is user-aligned via
417
+ // OS screen rotation; image's "up" still at file-right. To
418
+ // align image up with box up, rotate the OPPOSITE direction
419
+ // from the jsPortrait case — the screen-rotation already
420
+ // handles half the work; we just need to compensate for the
421
+ // remaining mismatch.
422
+ if (vertical) {
423
+ if (resolvedOrientation === 'landscape-left') return [{ rotate: '-90deg' }];
424
+ if (resolvedOrientation === 'landscape-right') return [{ rotate: '90deg' }];
425
+ return undefined;
426
+ }
427
+ if (resolvedOrientation === 'landscape-left') return [{ rotate: '90deg' }];
428
+ if (resolvedOrientation === 'landscape-right') return [{ rotate: '-90deg' }];
429
+ return undefined;
430
+ }, [resolvedOrientation, vertical]);
431
+
352
432
  const singleImageStyle = useMemo(
353
- () => {
354
- if (resolvedOrientation === 'landscape-left') {
355
- return [StyleSheet.absoluteFill, { transform: [{ rotate: '90deg' }] }];
356
- }
357
- if (resolvedOrientation === 'landscape-right') {
358
- return [StyleSheet.absoluteFill, { transform: [{ rotate: '-90deg' }] }];
359
- }
360
- return StyleSheet.absoluteFill;
361
- },
362
- [resolvedOrientation],
433
+ () =>
434
+ thumbRotationTransform
435
+ ? [StyleSheet.absoluteFill, { transform: thumbRotationTransform }]
436
+ : StyleSheet.absoluteFill,
437
+ [thumbRotationTransform],
438
+ );
439
+
440
+ // Same rotation applied to the per-keyframe (multi-thumb) tiles.
441
+ const multiThumbStyle = useMemo(
442
+ () =>
443
+ thumbRotationTransform
444
+ ? [styles.multiThumb, { transform: thumbRotationTransform }]
445
+ : styles.multiThumb,
446
+ [thumbRotationTransform],
363
447
  );
364
448
 
365
449
  return (
@@ -378,7 +462,9 @@ export function PanoramaBandOverlay({
378
462
  // looked detached when there were only a few thumbnails.
379
463
  <ScrollView
380
464
  ref={scrollRef}
381
- horizontal
465
+ // Horizontal scroll in JS-portrait bands; vertical scroll
466
+ // in JS-landscape (non-locked host) bands.
467
+ horizontal={layout.kind === 'portrait'}
382
468
  showsHorizontalScrollIndicator={false}
383
469
  showsVerticalScrollIndicator={false}
384
470
  style={styles.thumbScroll}
@@ -395,7 +481,7 @@ export function PanoramaBandOverlay({
395
481
  // defensive). URI segment helps RN's image cache key.
396
482
  key={`${idx}-${uri}`}
397
483
  source={{ uri }}
398
- style={styles.multiThumb}
484
+ style={multiThumbStyle}
399
485
  resizeMode="cover"
400
486
  fadeDuration={0}
401
487
  />
@@ -164,6 +164,20 @@ export function PanoramaSettingsModal({
164
164
  transparent
165
165
  statusBarTranslucent
166
166
  onRequestClose={onClose}
167
+ // v0.12.0 — RN's iOS Modal defaults to portrait-only. When a
168
+ // host removes its UIInterfaceOrientations portrait lock to
169
+ // support landscape capture, opening this modal while in
170
+ // landscape would force iOS to rotate the window scene to
171
+ // portrait, then the underlying <Camera>'s ARSession can end
172
+ // up with stale display-transform state on dismiss (preview
173
+ // renders sideways). Declaring all orientations keeps the
174
+ // window aligned with the device throughout the modal cycle.
175
+ supportedOrientations={[
176
+ 'portrait',
177
+ 'portrait-upside-down',
178
+ 'landscape-left',
179
+ 'landscape-right',
180
+ ]}
167
181
  >
168
182
  <View style={styles.backdrop}>
169
183
  <View style={styles.sheet}>
@@ -1,43 +1,49 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  /**
3
- * ViewportCropOverlay — V12.12.
3
+ * ViewportCropOverlay — V12.12 + v0.12.0 orientation-aware (R2-lite).
4
4
  *
5
5
  * Translucent dim bars on the camera preview's PAN-AXIS edges
6
- * showing where the panorama engine's source-crop is. Earlier
7
- * versions (V12.11 Step B) put the bars on JS-top/bottom because
8
- * the engine clipped the long sensor axis (perpendicular to pan
9
- * in landscape, along pan in portrait) — that produced visible
10
- * bars on the user-LEFT/RIGHT in landscape, which is the WRONG
11
- * place: those edges aren't what the engine clips.
6
+ * showing where the panorama engine's source-crop is. The engine
7
+ * clips ALONG the pan axis:
12
8
  *
13
- * V12.12: engine now clips ALONG the pan axis. In sensor-native
14
- * coords:
15
- * landscape capture (vertical pan): clip = sensor Y (rows).
16
- * User perceives this as TOP and BOTTOM of their landscape view.
17
- * • portrait capture (horizontal pan): clip = sensor X (cols).
18
- * User perceives this as LEFT and RIGHT of their portrait view.
9
+ * Portrait capture (horizontal pan / Mode B):
10
+ * clip = sensor X (cols). User perceives this as LEFT and RIGHT
11
+ * of their portrait view.
19
12
  *
20
- * In JS coords (the host app is portrait-locked):
21
- * portrait device: user-left/right == JS-left/right. Bars on
22
- * JS-left/right.
23
- * • landscape device: user-top/bottom == JS-left/right (because
24
- * the user's vertical maps to JS-horizontal
25
- * under portrait-lock). Bars on JS-left/right.
13
+ * Landscape capture (vertical pan / Mode A):
14
+ * clip = sensor Y (rows). User perceives this as TOP and BOTTOM
15
+ * of their landscape view.
26
16
  *
27
- * So in BOTH device orientations the bars sit at JS-left and JS-right.
28
- * **No orientation detection needed in this component.** The
29
- * engine has already arranged for the clip to manifest at the same
30
- * JS edges regardless of physical device orientation.
17
+ * ## v0.12.0 update (R2-lite)
31
18
  *
32
- * Bar width = `(1 - panFraction) / 2` of the JS-horizontal extent.
33
- * For the default `kPanAxisFractionRect = 0.70` engine constant,
34
- * each bar is 15 % wide visibly substantial, matching what the
35
- * engine clips out per frame.
19
+ * Pre-v0.12 this component assumed the host app was orientation-
20
+ * locked to portrait, in which case ALL device orientations mapped
21
+ * to JS-left + JS-right for the bars (because the user's vertical
22
+ * mapped to JS-horizontal under portrait-lock). Under R2-lite the
23
+ * SDK no longer holds the UI in portrait, so JS coordinates align
24
+ * with the physical device orientation reported by
25
+ * `useDeviceOrientation()`. The bars now live at:
26
+ *
27
+ * portrait, portrait-upside-down → JS-left + JS-right (horizontal pan)
28
+ * landscape-left, landscape-right → JS-top + JS-bottom (vertical pan)
29
+ *
30
+ * Mounting: the flagship `<Camera>` component mounts this overlay
31
+ * by default in v0.12.0 (PR-3 wiring); Layer-2 hosts can mount it
32
+ * themselves via the public export.
33
+ *
34
+ * ## Bar dimensions
35
+ *
36
+ * Bar `(1 - panFraction) / 2` of the pan-axis extent. For the
37
+ * default engine constant `kPanAxisFractionRect = 0.70`, each bar
38
+ * is 15 % of the pan-axis extent — visibly substantial, matching
39
+ * what the engine clips out per frame.
36
40
  */
37
41
 
38
42
  import React from 'react';
39
43
  import { StyleSheet, View } from 'react-native';
40
44
 
45
+ import { useDeviceOrientation } from './useDeviceOrientation';
46
+
41
47
 
42
48
  export interface ViewportCropOverlayProps {
43
49
  /**
@@ -52,15 +58,31 @@ export interface ViewportCropOverlayProps {
52
58
  export function ViewportCropOverlay({
53
59
  panFraction,
54
60
  }: ViewportCropOverlayProps): React.JSX.Element | null {
61
+ const orientation = useDeviceOrientation();
62
+
55
63
  if (panFraction >= 1) return null;
56
64
 
57
- // (1 - panFraction) / 2 of the JS-horizontal extent on each side.
65
+ // (1 - panFraction) / 2 of the pan-axis extent on each side.
58
66
  const barPercent = `${((1 - panFraction) / 2) * 100}%` as const;
59
67
 
68
+ const isLandscape =
69
+ orientation === 'landscape-left' || orientation === 'landscape-right';
70
+
60
71
  return (
61
72
  <View pointerEvents="none" style={styles.root}>
62
- <View style={[styles.bar, { left: 0, top: 0, bottom: 0, width: barPercent }]} />
63
- <View style={[styles.bar, { right: 0, top: 0, bottom: 0, width: barPercent }]} />
73
+ {isLandscape ? (
74
+ <>
75
+ {/* Vertical-pan capture: bars at JS-top + JS-bottom. */}
76
+ <View style={[styles.bar, { left: 0, right: 0, top: 0, height: barPercent }]} />
77
+ <View style={[styles.bar, { left: 0, right: 0, bottom: 0, height: barPercent }]} />
78
+ </>
79
+ ) : (
80
+ <>
81
+ {/* Horizontal-pan capture: bars at JS-left + JS-right. */}
82
+ <View style={[styles.bar, { left: 0, top: 0, bottom: 0, width: barPercent }]} />
83
+ <View style={[styles.bar, { right: 0, top: 0, bottom: 0, width: barPercent }]} />
84
+ </>
85
+ )}
64
86
  </View>
65
87
  );
66
88
  }
@@ -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`'