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
@@ -141,26 +141,50 @@ const MULTI_THUMB_HARD_CAP = 32; // safety net; host typically caps at 24
141
141
  * reads as user-right-arrow (pointing along the horizontal pan
142
142
  * direction).
143
143
  */
144
- function layoutFor(orientation) {
144
+ function layoutFor(orientation, vertical) {
145
145
  const commonInner = {
146
146
  alignItems: 'center',
147
147
  paddingHorizontal: BAND_PADDING,
148
148
  paddingVertical: BAND_PADDING,
149
149
  backgroundColor: 'rgba(0, 0, 0, 0.55)',
150
150
  };
151
- // 2026-05-19repositioned tethered to the shutter (no longer
152
- // edge-pinned via absolute positioning). The parent stack in
153
- // Camera.tsx now puts this band in a vertical column immediately
154
- // above the shutter row. The SDK's orientation lock holds the UI
155
- // in portrait regardless of physical device rotation, so the band
156
- // is ALWAYS a horizontal strip in JS coordinates. In landscape
157
- // (physically held), the rendered strip visually appears as a
158
- // vertical column on the viewport-side of the shutter.
151
+ // v0.12.0band structural orientation tracks the host's
152
+ // `vertical` flag (which the host derives from JS layout
153
+ // orientation):
159
154
  //
160
- // What still varies by physical orientation: the order in which
161
- // thumbnails should appear so newest is at the user-perceived
162
- // "leading edge" of the pan. That's the flexDirection (row vs
163
- // row-reverse) and the arrow glyph.
155
+ // vertical=false Horizontal strip in JS coords. Under
156
+ // portrait-lock + device-landscape this appears
157
+ // as a vertical column on user-right via the
158
+ // un-rotated framebuffer.
159
+ // vertical=true Vertical column in JS coords. Non-locked
160
+ // + device-landscape — band lives along the
161
+ // JS-side strip where the home indicator is.
162
+ //
163
+ // What still varies by physical orientation regardless: the
164
+ // thumbnail flow direction so newest sits at the user-perceived
165
+ // pan-leading edge (flexDirection + arrowGlyph).
166
+ if (vertical) {
167
+ // Vertical band in JS coords (non-locked landscape). The OS
168
+ // rotated the framebuffer so user-top = JS-top, user-bottom =
169
+ // JS-bottom — same scroll direction regardless of whether the
170
+ // device is landscape-left or landscape-right. Latest grows
171
+ // toward user-bottom (= JS-bottom). flexDirection 'column'
172
+ // puts array[0]/oldest at JS-top.
173
+ return {
174
+ kind: 'landscape',
175
+ band: {
176
+ marginHorizontal: 8,
177
+ marginVertical: 16,
178
+ width: BAND_THICKNESS,
179
+ flexDirection: 'column',
180
+ ...commonInner,
181
+ },
182
+ flexDirection: 'column',
183
+ arrowGlyph: '↓',
184
+ };
185
+ }
186
+ // vertical=false branch: pre-v0.12 horizontal-strip behavior
187
+ // keyed on device-physical orientation for thumbnail direction.
164
188
  if (orientation === 'landscape-left') {
165
189
  // Phone rotated 90° CCW from portrait (home indicator on the
166
190
  // user's RIGHT). With UI orientation-locked to portrait:
@@ -221,7 +245,7 @@ function layoutFor(orientation) {
221
245
  arrowGlyph: '→',
222
246
  };
223
247
  }
224
- function PanoramaBandOverlay({ state, frameUris, captureOrientation, }) {
248
+ function PanoramaBandOverlay({ state, frameUris, captureOrientation, vertical = false, }) {
225
249
  // 2026-05-18 (Issue #3 fix) — orientation source priority:
226
250
  // 1. `captureOrientation` prop from the host (4-way; correct
227
251
  // for landscape-left vs landscape-right disambiguation).
@@ -231,7 +255,7 @@ function PanoramaBandOverlay({ state, frameUris, captureOrientation, }) {
231
255
  // sensibly before any orientation info is available).
232
256
  const resolvedOrientation = captureOrientation
233
257
  ?? (state?.isLandscape ? 'landscape-left' : 'portrait');
234
- const layout = (0, react_1.useMemo)(() => layoutFor(resolvedOrientation), [resolvedOrientation]);
258
+ const layout = (0, react_1.useMemo)(() => layoutFor(resolvedOrientation, vertical), [resolvedOrientation, vertical]);
235
259
  const scrollRef = (0, react_1.useRef)(null);
236
260
  // Trim incoming URIs to a hard cap. The host already caps at 24
237
261
  // (AuditCaptureScreen) but defence-in-depth keeps the ScrollView
@@ -245,26 +269,22 @@ function PanoramaBandOverlay({ state, frameUris, captureOrientation, }) {
245
269
  : frameUris;
246
270
  }, [frameUris]);
247
271
  const hasMultiThumb = cappedFrameUris.length > 0;
248
- // Auto-scroll on content-size change.
249
- //
250
- // 2026-05-18 (Issue #4 fix-b): the direction depends on flex
251
- // direction. In `row` (portrait, landscape-right) the LATEST
252
- // item is at JS-rightmost → scrollToEnd shows it. In
253
- // `row-reverse` (landscape-left) the latest is at JS-leftmost →
254
- // scrollTo({x: 0}) shows it. The earlier always-scrollToEnd
255
- // behaviour scrolled to OLDEST in row-reverse, which hid the
256
- // just-captured frame off-screen at user-bottom.
272
+ // Auto-scroll on content-size change. `*-reverse` puts latest at
273
+ // scroll origin (scrollTo {0,0}); normal `row`/`column` puts
274
+ // latest at scroll end (scrollToEnd).
275
+ const isReverse = layout.flexDirection === 'row-reverse' ||
276
+ layout.flexDirection === 'column-reverse';
257
277
  const onContentSizeChange = (0, react_1.useCallback)(() => {
258
278
  const sv = scrollRef.current;
259
279
  if (!sv)
260
280
  return;
261
- if (layout.flexDirection === 'row-reverse') {
281
+ if (isReverse) {
262
282
  sv.scrollTo({ x: 0, y: 0, animated: false });
263
283
  }
264
284
  else {
265
285
  sv.scrollToEnd({ animated: false });
266
286
  }
267
- }, [layout.flexDirection]);
287
+ }, [isReverse]);
268
288
  // ── Single cumulative thumbnail (live-engine fallback) ──────────
269
289
  //
270
290
  // Same fill-ratio math as V12.14.9. Kept so live-stitching engines
@@ -285,25 +305,63 @@ function PanoramaBandOverlay({ state, frameUris, captureOrientation, }) {
285
305
  const singleThumbPanLen = (0, react_1.useMemo)(() => {
286
306
  return Math.max(SINGLE_THUMB_INNER, SINGLE_THUMB_MAX_PAN_LEN * fillRatio);
287
307
  }, [fillRatio]);
288
- // V12.14.9 rotate the panorama image 90° in landscape mode so
289
- // the captured scene reads UPRIGHT to the user in landscape head-up
290
- // view. See original comment in the pre-V16 PanoramaBandOverlay for
291
- // the full reasoning. Portrait+horizontal-pan mode (the other
292
- // supported mode) doesn't need rotation.
308
+ // Image rotation transform for thumbnails. Captured frames are in
309
+ // user-perspective orientation (the capture pipeline rotates the
310
+ // sensor-native bytes via `outputOrientation="device"` + EXIF
311
+ // baking in `normaliseOrientation`). The thumbnail BOX is in
312
+ // JS coords. When JS coords are device-aligned (portrait-lock,
313
+ // i.e. vertical=false here) and the device is in landscape, the
314
+ // image content is rotated 90° from the box's axes → appears
315
+ // sideways without compensation. Apply a counter-rotation to
316
+ // line content up with the box's perceived "top".
293
317
  //
294
- // 2026-05-18 (Issue #3) derive from `resolvedOrientation` instead
295
- // of the deprecated 2-way `isLandscape`. In landscape-RIGHT we
296
- // rotate −90° so the captured scene still reads upright (the
297
- // opposite sense from landscape-LEFT).
298
- const singleImageStyle = (0, react_1.useMemo)(() => {
299
- if (resolvedOrientation === 'landscape-left') {
300
- return [react_native_1.StyleSheet.absoluteFill, { transform: [{ rotate: '90deg' }] }];
301
- }
302
- if (resolvedOrientation === 'landscape-right') {
303
- return [react_native_1.StyleSheet.absoluteFill, { transform: [{ rotate: '-90deg' }] }];
318
+ // When vertical=true (non-locked + device-landscape; JS coords
319
+ // rotated with screen), the box IS user-aligned already. No
320
+ // rotation needed the image is already correctly oriented for
321
+ // direct display.
322
+ //
323
+ // V12.14.9 v0.12.0 — extended from single-thumb (cumulative
324
+ // panorama image fallback) to the multi-thumb path too. Pre-
325
+ // v0.12 the multi-thumb keyframe thumbnails had no rotation
326
+ // transform, so they appeared sideways in portrait-locked
327
+ // landscape captures (the case the example app's batch-keyframe
328
+ // engine hits).
329
+ const thumbRotationTransform = (0, react_1.useMemo)(() => {
330
+ // Empirical observation (on-device test 2026-05-28): captured
331
+ // per-keyframe JPEGs ARE saved in sensor-native landscape (not
332
+ // user-perspective), despite the cumulative panorama getting
333
+ // device-orientation rotation via finalize(). So:
334
+ //
335
+ // jsPortrait box + landscape device: box is device-aligned;
336
+ // image's "up" is at file-right (sensor convention). Rotate
337
+ // 90° CW (landscape-left) / 90° CCW (landscape-right) to
338
+ // align image up with box up.
339
+ // jsLandscape box + landscape device: box is user-aligned via
340
+ // OS screen rotation; image's "up" still at file-right. To
341
+ // align image up with box up, rotate the OPPOSITE direction
342
+ // from the jsPortrait case — the screen-rotation already
343
+ // handles half the work; we just need to compensate for the
344
+ // remaining mismatch.
345
+ if (vertical) {
346
+ if (resolvedOrientation === 'landscape-left')
347
+ return [{ rotate: '-90deg' }];
348
+ if (resolvedOrientation === 'landscape-right')
349
+ return [{ rotate: '90deg' }];
350
+ return undefined;
304
351
  }
305
- return react_native_1.StyleSheet.absoluteFill;
306
- }, [resolvedOrientation]);
352
+ if (resolvedOrientation === 'landscape-left')
353
+ return [{ rotate: '90deg' }];
354
+ if (resolvedOrientation === 'landscape-right')
355
+ return [{ rotate: '-90deg' }];
356
+ return undefined;
357
+ }, [resolvedOrientation, vertical]);
358
+ const singleImageStyle = (0, react_1.useMemo)(() => thumbRotationTransform
359
+ ? [react_native_1.StyleSheet.absoluteFill, { transform: thumbRotationTransform }]
360
+ : react_native_1.StyleSheet.absoluteFill, [thumbRotationTransform]);
361
+ // Same rotation applied to the per-keyframe (multi-thumb) tiles.
362
+ const multiThumbStyle = (0, react_1.useMemo)(() => thumbRotationTransform
363
+ ? [styles.multiThumb, { transform: thumbRotationTransform }]
364
+ : styles.multiThumb, [thumbRotationTransform]);
307
365
  return (react_1.default.createElement(react_native_1.View, { pointerEvents: "none", style: [styles.bandBase, layout.band] }, hasMultiThumb ? (
308
366
  // Multi-thumb path: one image per accepted keyframe, scrolling
309
367
  // horizontally (in JS-coords) within the band. Content
@@ -316,7 +374,10 @@ function PanoramaBandOverlay({ state, frameUris, captureOrientation, }) {
316
374
  // adjacent to the newest thumbnail. Previously it was a
317
375
  // sibling of the ScrollView at the band's far end, which
318
376
  // looked detached when there were only a few thumbnails.
319
- react_1.default.createElement(react_native_1.ScrollView, { ref: scrollRef, horizontal: true, showsHorizontalScrollIndicator: false, showsVerticalScrollIndicator: false, style: styles.thumbScroll, contentContainerStyle: [
377
+ react_1.default.createElement(react_native_1.ScrollView, { ref: scrollRef,
378
+ // Horizontal scroll in JS-portrait bands; vertical scroll
379
+ // in JS-landscape (non-locked host) bands.
380
+ horizontal: layout.kind === 'portrait', showsHorizontalScrollIndicator: false, showsVerticalScrollIndicator: false, style: styles.thumbScroll, contentContainerStyle: [
320
381
  styles.thumbScrollContent,
321
382
  { flexDirection: layout.flexDirection },
322
383
  ], onContentSizeChange: onContentSizeChange },
@@ -328,7 +389,7 @@ function PanoramaBandOverlay({ state, frameUris, captureOrientation, }) {
328
389
  // Composite key: idx prevents collisions if the same path
329
390
  // ever gets re-emitted (shouldn't happen but cheap to be
330
391
  // defensive). URI segment helps RN's image cache key.
331
- key: `${idx}-${uri}`, source: { uri }, style: styles.multiThumb, resizeMode: "cover", fadeDuration: 0 }))),
392
+ key: `${idx}-${uri}`, source: { uri }, style: multiThumbStyle, resizeMode: "cover", fadeDuration: 0 }))),
332
393
  react_1.default.createElement(react_native_1.View, { style: styles.arrowTrack },
333
394
  react_1.default.createElement(react_native_1.Text, { style: styles.arrowGlyph }, layout.arrowGlyph)))) : (react_1.default.createElement(react_1.default.Fragment, null,
334
395
  react_1.default.createElement(react_native_1.View, { style: [
@@ -145,7 +145,21 @@ function PanoramaSettingsModal({ visible, settings, onChange, onClose, }) {
145
145
  // Flow-tunables section. Mirrors the type-level optionality of
146
146
  // `frameSelection.flow`.
147
147
  const showFlowTunables = settings.frameSelection.mode === 'flow-based';
148
- return (react_1.default.createElement(react_native_1.Modal, { visible: visible, animationType: "slide", transparent: true, statusBarTranslucent: true, onRequestClose: onClose },
148
+ return (react_1.default.createElement(react_native_1.Modal, { visible: visible, animationType: "slide", transparent: true, statusBarTranslucent: true, onRequestClose: onClose,
149
+ // v0.12.0 — RN's iOS Modal defaults to portrait-only. When a
150
+ // host removes its UIInterfaceOrientations portrait lock to
151
+ // support landscape capture, opening this modal while in
152
+ // landscape would force iOS to rotate the window scene to
153
+ // portrait, then the underlying <Camera>'s ARSession can end
154
+ // up with stale display-transform state on dismiss (preview
155
+ // renders sideways). Declaring all orientations keeps the
156
+ // window aligned with the device throughout the modal cycle.
157
+ supportedOrientations: [
158
+ 'portrait',
159
+ 'portrait-upside-down',
160
+ 'landscape-left',
161
+ 'landscape-right',
162
+ ] },
149
163
  react_1.default.createElement(react_native_1.View, { style: styles.backdrop },
150
164
  react_1.default.createElement(react_native_1.View, { style: styles.sheet },
151
165
  react_1.default.createElement(react_native_1.View, { style: styles.header },
@@ -1,37 +1,41 @@
1
1
  /**
2
- * ViewportCropOverlay — V12.12.
2
+ * ViewportCropOverlay — V12.12 + v0.12.0 orientation-aware (R2-lite).
3
3
  *
4
4
  * Translucent dim bars on the camera preview's PAN-AXIS edges
5
- * showing where the panorama engine's source-crop is. Earlier
6
- * versions (V12.11 Step B) put the bars on JS-top/bottom because
7
- * the engine clipped the long sensor axis (perpendicular to pan
8
- * in landscape, along pan in portrait) — that produced visible
9
- * bars on the user-LEFT/RIGHT in landscape, which is the WRONG
10
- * place: those edges aren't what the engine clips.
11
- *
12
- * V12.12: engine now clips ALONG the pan axis. In sensor-native
13
- * coords:
14
- * landscape capture (vertical pan): clip = sensor Y (rows).
15
- * User perceives this as TOP and BOTTOM of their landscape view.
16
- * portrait capture (horizontal pan): clip = sensor X (cols).
17
- * User perceives this as LEFT and RIGHT of their portrait view.
18
- *
19
- * In JS coords (the host app is portrait-locked):
20
- * portrait device: user-left/right == JS-left/right. Bars on
21
- * JS-left/right.
22
- * landscape device: user-top/bottom == JS-left/right (because
23
- * the user's vertical maps to JS-horizontal
24
- * under portrait-lock). Bars on JS-left/right.
25
- *
26
- * So in BOTH device orientations the bars sit at JS-left and JS-right.
27
- * **No orientation detection needed in this component.** The
28
- * engine has already arranged for the clip to manifest at the same
29
- * JS edges regardless of physical device orientation.
30
- *
31
- * Bar width = `(1 - panFraction) / 2` of the JS-horizontal extent.
32
- * For the default `kPanAxisFractionRect = 0.70` engine constant,
33
- * each bar is 15 % wide — visibly substantial, matching what the
34
- * engine clips out per frame.
5
+ * showing where the panorama engine's source-crop is. The engine
6
+ * clips ALONG the pan axis:
7
+ *
8
+ * Portrait capture (horizontal pan / Mode B):
9
+ * clip = sensor X (cols). User perceives this as LEFT and RIGHT
10
+ * of their portrait view.
11
+ *
12
+ * Landscape capture (vertical pan / Mode A):
13
+ * clip = sensor Y (rows). User perceives this as TOP and BOTTOM
14
+ * of their landscape view.
15
+ *
16
+ * ## v0.12.0 update (R2-lite)
17
+ *
18
+ * Pre-v0.12 this component assumed the host app was orientation-
19
+ * locked to portrait, in which case ALL device orientations mapped
20
+ * to JS-left + JS-right for the bars (because the user's vertical
21
+ * mapped to JS-horizontal under portrait-lock). Under R2-lite the
22
+ * SDK no longer holds the UI in portrait, so JS coordinates align
23
+ * with the physical device orientation reported by
24
+ * `useDeviceOrientation()`. The bars now live at:
25
+ *
26
+ * portrait, portrait-upside-down JS-left + JS-right (horizontal pan)
27
+ * landscape-left, landscape-right JS-top + JS-bottom (vertical pan)
28
+ *
29
+ * Mounting: the flagship `<Camera>` component mounts this overlay
30
+ * by default in v0.12.0 (PR-3 wiring); Layer-2 hosts can mount it
31
+ * themselves via the public export.
32
+ *
33
+ * ## Bar dimensions
34
+ *
35
+ * Bar `(1 - panFraction) / 2` of the pan-axis extent. For the
36
+ * default engine constant `kPanAxisFractionRect = 0.70`, each bar
37
+ * is 15 % of the pan-axis extent — visibly substantial, matching
38
+ * what the engine clips out per frame.
35
39
  */
36
40
  import React from 'react';
37
41
  export interface ViewportCropOverlayProps {
@@ -1,39 +1,43 @@
1
1
  "use strict";
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
  /**
4
- * ViewportCropOverlay — V12.12.
4
+ * ViewportCropOverlay — V12.12 + v0.12.0 orientation-aware (R2-lite).
5
5
  *
6
6
  * Translucent dim bars on the camera preview's PAN-AXIS edges
7
- * showing where the panorama engine's source-crop is. Earlier
8
- * versions (V12.11 Step B) put the bars on JS-top/bottom because
9
- * the engine clipped the long sensor axis (perpendicular to pan
10
- * in landscape, along pan in portrait) — that produced visible
11
- * bars on the user-LEFT/RIGHT in landscape, which is the WRONG
12
- * place: those edges aren't what the engine clips.
7
+ * showing where the panorama engine's source-crop is. The engine
8
+ * clips ALONG the pan axis:
13
9
  *
14
- * V12.12: engine now clips ALONG the pan axis. In sensor-native
15
- * coords:
16
- * landscape capture (vertical pan): clip = sensor Y (rows).
17
- * User perceives this as TOP and BOTTOM of their landscape view.
18
- * • portrait capture (horizontal pan): clip = sensor X (cols).
19
- * User perceives this as LEFT and RIGHT of their portrait view.
10
+ * Portrait capture (horizontal pan / Mode B):
11
+ * clip = sensor X (cols). User perceives this as LEFT and RIGHT
12
+ * of their portrait view.
20
13
  *
21
- * In JS coords (the host app is portrait-locked):
22
- * portrait device: user-left/right == JS-left/right. Bars on
23
- * JS-left/right.
24
- * • landscape device: user-top/bottom == JS-left/right (because
25
- * the user's vertical maps to JS-horizontal
26
- * under portrait-lock). Bars on JS-left/right.
14
+ * Landscape capture (vertical pan / Mode A):
15
+ * clip = sensor Y (rows). User perceives this as TOP and BOTTOM
16
+ * of their landscape view.
27
17
  *
28
- * So in BOTH device orientations the bars sit at JS-left and JS-right.
29
- * **No orientation detection needed in this component.** The
30
- * engine has already arranged for the clip to manifest at the same
31
- * JS edges regardless of physical device orientation.
18
+ * ## v0.12.0 update (R2-lite)
32
19
  *
33
- * Bar width = `(1 - panFraction) / 2` of the JS-horizontal extent.
34
- * For the default `kPanAxisFractionRect = 0.70` engine constant,
35
- * each bar is 15 % wide visibly substantial, matching what the
36
- * engine clips out per frame.
20
+ * Pre-v0.12 this component assumed the host app was orientation-
21
+ * locked to portrait, in which case ALL device orientations mapped
22
+ * to JS-left + JS-right for the bars (because the user's vertical
23
+ * mapped to JS-horizontal under portrait-lock). Under R2-lite the
24
+ * SDK no longer holds the UI in portrait, so JS coordinates align
25
+ * with the physical device orientation reported by
26
+ * `useDeviceOrientation()`. The bars now live at:
27
+ *
28
+ * portrait, portrait-upside-down → JS-left + JS-right (horizontal pan)
29
+ * landscape-left, landscape-right → JS-top + JS-bottom (vertical pan)
30
+ *
31
+ * Mounting: the flagship `<Camera>` component mounts this overlay
32
+ * by default in v0.12.0 (PR-3 wiring); Layer-2 hosts can mount it
33
+ * themselves via the public export.
34
+ *
35
+ * ## Bar dimensions
36
+ *
37
+ * Bar `(1 - panFraction) / 2` of the pan-axis extent. For the
38
+ * default engine constant `kPanAxisFractionRect = 0.70`, each bar
39
+ * is 15 % of the pan-axis extent — visibly substantial, matching
40
+ * what the engine clips out per frame.
37
41
  */
38
42
  var __importDefault = (this && this.__importDefault) || function (mod) {
39
43
  return (mod && mod.__esModule) ? mod : { "default": mod };
@@ -42,14 +46,19 @@ Object.defineProperty(exports, "__esModule", { value: true });
42
46
  exports.ViewportCropOverlay = ViewportCropOverlay;
43
47
  const react_1 = __importDefault(require("react"));
44
48
  const react_native_1 = require("react-native");
49
+ const useDeviceOrientation_1 = require("./useDeviceOrientation");
45
50
  function ViewportCropOverlay({ panFraction, }) {
51
+ const orientation = (0, useDeviceOrientation_1.useDeviceOrientation)();
46
52
  if (panFraction >= 1)
47
53
  return null;
48
- // (1 - panFraction) / 2 of the JS-horizontal extent on each side.
54
+ // (1 - panFraction) / 2 of the pan-axis extent on each side.
49
55
  const barPercent = `${((1 - panFraction) / 2) * 100}%`;
50
- return (react_1.default.createElement(react_native_1.View, { pointerEvents: "none", style: styles.root },
56
+ const isLandscape = orientation === 'landscape-left' || orientation === 'landscape-right';
57
+ return (react_1.default.createElement(react_native_1.View, { pointerEvents: "none", style: styles.root }, isLandscape ? (react_1.default.createElement(react_1.default.Fragment, null,
58
+ react_1.default.createElement(react_native_1.View, { style: [styles.bar, { left: 0, right: 0, top: 0, height: barPercent }] }),
59
+ react_1.default.createElement(react_native_1.View, { style: [styles.bar, { left: 0, right: 0, bottom: 0, height: barPercent }] }))) : (react_1.default.createElement(react_1.default.Fragment, null,
51
60
  react_1.default.createElement(react_native_1.View, { style: [styles.bar, { left: 0, top: 0, bottom: 0, width: barPercent }] }),
52
- react_1.default.createElement(react_native_1.View, { style: [styles.bar, { right: 0, top: 0, bottom: 0, width: barPercent }] })));
61
+ react_1.default.createElement(react_native_1.View, { style: [styles.bar, { right: 0, top: 0, bottom: 0, width: barPercent }] })))));
53
62
  }
54
63
  const styles = react_native_1.StyleSheet.create({
55
64
  root: {
@@ -1,15 +1,24 @@
1
1
  /**
2
2
  * useDeviceOrientation — physical device orientation hook.
3
3
  *
4
- * The host app is portrait-locked at the iOS app level (so the
5
- * camera preview, header, controls, and thumbnails stay in their
6
- * portrait positions even when the user holds the phone sideways
7
- * for a vertical pan). But text overlays — the REC banner, the
8
- * pan-speed pill, the live frame strip — need to follow the
9
- * physical device orientation so they stay readable in the user's
10
- * hands. RN's `useWindowDimensions` can't help with this when
11
- * the app is orientation-locked: window dimensions don't change
12
- * when only the device rotates.
4
+ * Hooks into the accelerometer to report the device's physical
5
+ * orientation as a 4-way `DeviceOrientation` value. Works
6
+ * identically regardless of host configuration:
7
+ *
8
+ * - Portrait-locked host (Info.plist UISupportedInterfaceOrientations
9
+ * restricted to Portrait): RN's `useWindowDimensions` returns
10
+ * portrait dims regardless of physical tilt. This hook reads
11
+ * the sensor directly, so text overlays (REC banner, pan-speed
12
+ * pill, live frame strip) can still follow the user's hold.
13
+ * - Non-locked host (Info.plist supports all 4): the OS rotates
14
+ * the framebuffer with the device; `useWindowDimensions` reflects
15
+ * the rotated JS layout. This hook still reports physical tilt
16
+ * — useful in combination with window dims to detect whether
17
+ * the screen rotated to match the device (`<Camera>`'s v0.12
18
+ * `homeIndicatorEdge` logic uses both signals together).
19
+ *
20
+ * Either way the sensor is the single source of truth for "where
21
+ * the user's hands actually are."
13
22
  *
14
23
  * 2026-05-21 (v0.2 — Expo modules removal) — rewritten back onto
15
24
  * `react-native-sensors` accelerometer. `expo-sensors`'
@@ -3,15 +3,24 @@
3
3
  /**
4
4
  * useDeviceOrientation — physical device orientation hook.
5
5
  *
6
- * The host app is portrait-locked at the iOS app level (so the
7
- * camera preview, header, controls, and thumbnails stay in their
8
- * portrait positions even when the user holds the phone sideways
9
- * for a vertical pan). But text overlays — the REC banner, the
10
- * pan-speed pill, the live frame strip — need to follow the
11
- * physical device orientation so they stay readable in the user's
12
- * hands. RN's `useWindowDimensions` can't help with this when
13
- * the app is orientation-locked: window dimensions don't change
14
- * when only the device rotates.
6
+ * Hooks into the accelerometer to report the device's physical
7
+ * orientation as a 4-way `DeviceOrientation` value. Works
8
+ * identically regardless of host configuration:
9
+ *
10
+ * - Portrait-locked host (Info.plist UISupportedInterfaceOrientations
11
+ * restricted to Portrait): RN's `useWindowDimensions` returns
12
+ * portrait dims regardless of physical tilt. This hook reads
13
+ * the sensor directly, so text overlays (REC banner, pan-speed
14
+ * pill, live frame strip) can still follow the user's hold.
15
+ * - Non-locked host (Info.plist supports all 4): the OS rotates
16
+ * the framebuffer with the device; `useWindowDimensions` reflects
17
+ * the rotated JS layout. This hook still reports physical tilt
18
+ * — useful in combination with window dims to detect whether
19
+ * the screen rotated to match the device (`<Camera>`'s v0.12
20
+ * `homeIndicatorEdge` logic uses both signals together).
21
+ *
22
+ * Either way the sensor is the single source of truth for "where
23
+ * the user's hands actually are."
15
24
  *
16
25
  * 2026-05-21 (v0.2 — Expo modules removal) — rewritten back onto
17
26
  * `react-native-sensors` accelerometer. `expo-sensors`'
@@ -0,0 +1,104 @@
1
+ /**
2
+ * useOrientationDrift — detects mid-capture device rotation.
3
+ *
4
+ * Pairs with `useDeviceOrientation()` to surface the case where the
5
+ * user rotates the device *during* an active capture. The
6
+ * incremental stitching engine supports both portrait (Mode B,
7
+ * horizontal pan) and landscape (Mode A, vertical pan) capture
8
+ * modes as first-class — but mixing them mid-capture produces
9
+ * malformed output ("cross-mode capture is best-effort," per
10
+ * `incremental.ts:373-403`). Hosts that want to protect against
11
+ * this use this hook + `OrientationDriftModal` together: the
12
+ * `<Camera>` flagship component auto-abandons capture the instant
13
+ * `drifted === true` (PR-2 wiring); the modal surfaces an
14
+ * explanatory popup to the user.
15
+ *
16
+ * ## API contract
17
+ *
18
+ * Pass `active` true while a capture is in flight, false otherwise.
19
+ * Returns:
20
+ *
21
+ * - `captureOrientation` — the orientation snapshotted at the
22
+ * moment `active` transitioned false → true. `undefined` when
23
+ * `active` is false.
24
+ * - `currentOrientation` — live orientation from
25
+ * `useDeviceOrientation()`. Always defined (defaults to
26
+ * `'portrait'` until the accelerometer's first sample).
27
+ * - `drifted` — `true` IFF `active` is currently true AND
28
+ * `currentOrientation !== captureOrientation` at some point
29
+ * since the snapshot. **Latching** — once true, stays true
30
+ * until `active` flips back to false. This is intentional:
31
+ * after detection, callers should auto-abandon the capture
32
+ * (engine `stop()`); allowing the flag to clear before then
33
+ * would mask the drift if the user rotated back to the
34
+ * original orientation between the detection tick and the
35
+ * callers' abandonment effect.
36
+ *
37
+ * ## Semantics by transition
38
+ *
39
+ * - `active` false → true: snapshot `currentOrientation`;
40
+ * reset `drifted` to false.
41
+ * - `active` true (steady): if `currentOrientation !==
42
+ * captureOrientation` at any point, latch `drifted = true`.
43
+ * - `active` true → false: clear snapshot; reset `drifted`.
44
+ *
45
+ * ## Why a separate hook (rather than inlining in `<Camera>`)
46
+ *
47
+ * Hosts using the Layer-2 building blocks (`CameraView` directly,
48
+ * custom capture UX) can reuse this hook without mounting the
49
+ * full `<Camera>` flagship. Same composition pattern as
50
+ * `useIMUTranslationGate` and `useKeyframeStream`.
51
+ *
52
+ * ## Testing
53
+ *
54
+ * The pure state-transition function `_computeDriftStateForTests`
55
+ * is exported separately so jest can exercise all 5 transition
56
+ * cases without booting a React render. The hook itself is a
57
+ * thin wrapper around it (verified via on-device manual flow in
58
+ * the v0.12 verification checklist).
59
+ */
60
+ import { type DeviceOrientation } from './useDeviceOrientation';
61
+ export interface UseOrientationDriftReturn {
62
+ /**
63
+ * `true` IFF a capture is active and the device has rotated since
64
+ * the snapshot taken at capture start. Latching: once true, stays
65
+ * true until `active` flips false.
66
+ */
67
+ drifted: boolean;
68
+ /**
69
+ * Snapshot of `currentOrientation` at the moment `active`
70
+ * transitioned false → true. `undefined` when `active` is false.
71
+ */
72
+ captureOrientation: DeviceOrientation | undefined;
73
+ /**
74
+ * Live device orientation from `useDeviceOrientation()`. Always
75
+ * defined. Exposed so callers (e.g. the drift modal) can show
76
+ * "captured in PORTRAIT, now LANDSCAPE-LEFT" copy without
77
+ * mounting `useDeviceOrientation()` themselves.
78
+ */
79
+ currentOrientation: DeviceOrientation;
80
+ }
81
+ /**
82
+ * Internal state of the drift detector. Two scalar pieces: the
83
+ * snapshotted capture orientation (undefined when inactive) + the
84
+ * latched drift flag.
85
+ */
86
+ interface DriftState {
87
+ captureOrientation: DeviceOrientation | undefined;
88
+ drifted: boolean;
89
+ }
90
+ /**
91
+ * Pure state-transition function for the drift detector. Exported
92
+ * with a `_` prefix to signal "internal — not part of the public
93
+ * API." Jest uses this directly so tests don't need a React
94
+ * renderer (the lib's jest config is pure-data / no RN preset).
95
+ *
96
+ * Given the previous state + the current `active` flag + the
97
+ * current device orientation, returns the new state. Idempotent
98
+ * when nothing changed (returns the same object reference) so
99
+ * downstream `useState(setState)` calls become no-ops.
100
+ */
101
+ export declare function _computeDriftStateForTests(prev: DriftState, active: boolean, currentOrientation: DeviceOrientation): DriftState;
102
+ export declare function useOrientationDrift(active: boolean): UseOrientationDriftReturn;
103
+ export {};
104
+ //# sourceMappingURL=useOrientationDrift.d.ts.map