react-native-image-stitcher 0.11.0 → 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 (41) hide show
  1. package/CHANGELOG.md +116 -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/dist/stitching/useStitcherWorklet.js +25 -0
  23. package/ios/Sources/RNImageStitcher/ARSessionBridge.swift +7 -1
  24. package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.h +4 -3
  25. package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.mm +9 -7
  26. package/ios/Sources/RNImageStitcher/RNSARSession.swift +46 -7
  27. package/ios/Sources/RNImageStitcher/StitcherJsiInstaller.mm +58 -1
  28. package/package.json +2 -1
  29. package/src/camera/ARCameraView.tsx +18 -1
  30. package/src/camera/Camera.tsx +280 -13
  31. package/src/camera/OrientationDriftModal.tsx +224 -0
  32. package/src/camera/PanoramaBandOverlay.tsx +135 -49
  33. package/src/camera/PanoramaSettingsModal.tsx +14 -0
  34. package/src/camera/ViewportCropOverlay.tsx +52 -30
  35. package/src/camera/__tests__/useOrientationDrift.test.ts +169 -0
  36. package/src/camera/useDeviceOrientation.ts +18 -9
  37. package/src/camera/useOrientationDrift.ts +172 -0
  38. package/src/index.ts +13 -0
  39. package/src/stitching/__tests__/useStitcherWorklet.test.ts +202 -0
  40. package/src/stitching/incremental.ts +5 -3
  41. package/src/stitching/useStitcherWorklet.ts +25 -0
@@ -0,0 +1,159 @@
1
+ "use strict";
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * OrientationDriftModal — informational popup shown when the SDK
5
+ * auto-abandons an in-progress capture because the device rotated
6
+ * between Mode A (landscape + vertical pan) and Mode B (portrait
7
+ * + horizontal pan) mid-flight.
8
+ *
9
+ * ## When this modal appears
10
+ *
11
+ * In the v0.12 `<Camera>` integration, the modal is rendered while
12
+ * `useOrientationDrift(active).drifted === true`. By the time the
13
+ * modal renders, the capture has ALREADY been stopped (the
14
+ * `<Camera>` component's drift effect calls the engine's `stop()`
15
+ * the same render). The modal exists solely to explain to the
16
+ * user what happened — no "Continue" / "Resume" affordance because
17
+ * the engine docstring at `incremental.ts:373-403` is explicit
18
+ * that cross-mode capture is "best-effort, not supported" and
19
+ * continuing past drift produces malformed output.
20
+ *
21
+ * ## Layer-2 host usage
22
+ *
23
+ * Hosts using `CameraView` directly (rather than the flagship
24
+ * `<Camera>`) can compose this modal with `useOrientationDrift`
25
+ * for the same auto-abandon UX:
26
+ *
27
+ * const drift = useOrientationDrift(captureActive);
28
+ * useEffect(() => {
29
+ * if (drift.drifted) {
30
+ * // host abandons capture (engine stop + state cleanup)
31
+ * stopCapture();
32
+ * }
33
+ * }, [drift.drifted]);
34
+ *
35
+ * return <>
36
+ * <CameraView ... />
37
+ * <OrientationDriftModal
38
+ * visible={drift.drifted}
39
+ * captureOrientation={drift.captureOrientation}
40
+ * currentOrientation={drift.currentOrientation}
41
+ * onAcknowledge={dismissDriftModal}
42
+ * />
43
+ * </>;
44
+ *
45
+ * ## Accessibility
46
+ *
47
+ * Modal `role` defaults to RN's native dialog handling. The OK
48
+ * button carries an `accessibilityRole='button'` + label. Body
49
+ * text uses `accessibilityRole='text'` so the orientation summary
50
+ * is read by VoiceOver / TalkBack.
51
+ */
52
+ var __importDefault = (this && this.__importDefault) || function (mod) {
53
+ return (mod && mod.__esModule) ? mod : { "default": mod };
54
+ };
55
+ Object.defineProperty(exports, "__esModule", { value: true });
56
+ exports.OrientationDriftModal = OrientationDriftModal;
57
+ const react_1 = __importDefault(require("react"));
58
+ const react_native_1 = require("react-native");
59
+ /**
60
+ * Pretty-print a `DeviceOrientation` for body copy. Returns the
61
+ * uppercase form because the modal copy reads as "Capture started
62
+ * in PORTRAIT, now LANDSCAPE-LEFT" — uppercase orientations stand
63
+ * out from the surrounding lowercase sentence.
64
+ */
65
+ function formatOrientation(o) {
66
+ switch (o) {
67
+ case 'portrait':
68
+ return 'PORTRAIT';
69
+ case 'portrait-upside-down':
70
+ return 'PORTRAIT-UPSIDE-DOWN';
71
+ case 'landscape-left':
72
+ return 'LANDSCAPE-LEFT';
73
+ case 'landscape-right':
74
+ return 'LANDSCAPE-RIGHT';
75
+ }
76
+ }
77
+ function OrientationDriftModal(props) {
78
+ const { visible, captureOrientation, currentOrientation, onAcknowledge } = props;
79
+ return (react_1.default.createElement(react_native_1.Modal, { visible: visible, transparent: true, animationType: "fade", onRequestClose: onAcknowledge, accessibilityLabel: "Capture cancelled \u2014 orientation drift",
80
+ // v0.12.0 — see PanoramaSettingsModal for the same prop's
81
+ // rationale. Declaring all orientations prevents iOS from
82
+ // force-rotating the window to portrait when this modal opens
83
+ // mid-rotation, which would otherwise leave the underlying
84
+ // <Camera>'s ARSession in a stale-orientation state on dismiss.
85
+ supportedOrientations: [
86
+ 'portrait',
87
+ 'portrait-upside-down',
88
+ 'landscape-left',
89
+ 'landscape-right',
90
+ ] },
91
+ react_1.default.createElement(react_native_1.View, { style: styles.backdrop },
92
+ react_1.default.createElement(react_native_1.View, { style: styles.card },
93
+ react_1.default.createElement(react_native_1.Text, { style: styles.title, accessibilityRole: "header" }, "Capture cancelled"),
94
+ react_1.default.createElement(react_native_1.Text, { style: styles.body, accessibilityRole: "text" }, "Rotation detected mid-capture. Please hold the device steady and try again."),
95
+ captureOrientation !== undefined && (react_1.default.createElement(react_native_1.Text, { style: styles.subBody, accessibilityRole: "text" },
96
+ "Capture started in ",
97
+ formatOrientation(captureOrientation),
98
+ ", now ",
99
+ formatOrientation(currentOrientation),
100
+ ".")),
101
+ react_1.default.createElement(react_native_1.Pressable, { style: ({ pressed }) => [
102
+ styles.button,
103
+ pressed && styles.buttonPressed,
104
+ ], onPress: onAcknowledge, accessibilityRole: "button", accessibilityLabel: "OK" },
105
+ react_1.default.createElement(react_native_1.Text, { style: styles.buttonLabel }, "OK"))))));
106
+ }
107
+ const styles = react_native_1.StyleSheet.create({
108
+ backdrop: {
109
+ flex: 1,
110
+ backgroundColor: 'rgba(0, 0, 0, 0.6)',
111
+ alignItems: 'center',
112
+ justifyContent: 'center',
113
+ paddingHorizontal: 32,
114
+ },
115
+ card: {
116
+ backgroundColor: '#1c1c1e',
117
+ borderRadius: 14,
118
+ paddingHorizontal: 20,
119
+ paddingVertical: 24,
120
+ width: '100%',
121
+ maxWidth: 340,
122
+ },
123
+ title: {
124
+ color: '#fff',
125
+ fontSize: 18,
126
+ fontWeight: '600',
127
+ marginBottom: 12,
128
+ textAlign: 'center',
129
+ },
130
+ body: {
131
+ color: '#e5e5ea',
132
+ fontSize: 15,
133
+ lineHeight: 21,
134
+ textAlign: 'center',
135
+ marginBottom: 12,
136
+ },
137
+ subBody: {
138
+ color: '#8e8e93',
139
+ fontSize: 13,
140
+ lineHeight: 18,
141
+ textAlign: 'center',
142
+ marginBottom: 20,
143
+ },
144
+ button: {
145
+ backgroundColor: '#0a84ff',
146
+ borderRadius: 10,
147
+ paddingVertical: 12,
148
+ alignItems: 'center',
149
+ },
150
+ buttonPressed: {
151
+ backgroundColor: '#0860c0',
152
+ },
153
+ buttonLabel: {
154
+ color: '#fff',
155
+ fontSize: 17,
156
+ fontWeight: '600',
157
+ },
158
+ });
159
+ //# sourceMappingURL=OrientationDriftModal.js.map
@@ -70,6 +70,18 @@ import type { IncrementalState } from '../stitching/incremental';
70
70
  */
71
71
  export type BandCaptureOrientation = 'portrait' | 'portrait-upside-down' | 'landscape-left' | 'landscape-right';
72
72
  export interface PanoramaBandOverlayProps {
73
+ /**
74
+ * v0.12.0 — `true` when the band should render as a vertical
75
+ * column in JS (anchor edge is JS-left or JS-right, i.e.
76
+ * non-locked host with device-landscape). `false` (default)
77
+ * renders the legacy horizontal strip — covers portrait-locked
78
+ * hosts in any device orientation AND non-locked hosts in
79
+ * portrait. The flagship `<Camera>` derives this from
80
+ * `useWindowDimensions()` + `useDeviceOrientation()` (see
81
+ * `homeIndicatorEdge` in `Camera.tsx`); Layer-2 hosts pass it
82
+ * directly.
83
+ */
84
+ vertical?: boolean;
73
85
  /**
74
86
  * Latest engine state. Pass `useIncrementalStitcher().state`.
75
87
  * Used for single-thumb fallback URI and fill-ratio when no
@@ -103,5 +115,5 @@ export interface PanoramaBandOverlayProps {
103
115
  */
104
116
  captureOrientation?: BandCaptureOrientation;
105
117
  }
106
- export declare function PanoramaBandOverlay({ state, frameUris, captureOrientation, }: PanoramaBandOverlayProps): React.JSX.Element | null;
118
+ export declare function PanoramaBandOverlay({ state, frameUris, captureOrientation, vertical, }: PanoramaBandOverlayProps): React.JSX.Element | null;
107
119
  //# sourceMappingURL=PanoramaBandOverlay.d.ts.map
@@ -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`'