react-native-image-stitcher 0.11.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CHANGELOG.md +151 -0
  2. package/README.md +28 -0
  3. package/android/src/main/java/io/imagestitcher/rn/IncrementalFirstwinsEngine.kt +3 -2
  4. package/dist/camera/ARCameraView.d.ts +10 -0
  5. package/dist/camera/ARCameraView.js +1 -0
  6. package/dist/camera/Camera.d.ts +191 -0
  7. package/dist/camera/Camera.js +250 -9
  8. package/dist/camera/OrientationDriftModal.d.ts +83 -0
  9. package/dist/camera/OrientationDriftModal.js +159 -0
  10. package/dist/camera/PanoramaBandOverlay.d.ts +13 -1
  11. package/dist/camera/PanoramaBandOverlay.js +106 -45
  12. package/dist/camera/PanoramaSettingsModal.js +15 -1
  13. package/dist/camera/ViewportCropOverlay.d.ts +35 -31
  14. package/dist/camera/ViewportCropOverlay.js +39 -30
  15. package/dist/camera/useDeviceOrientation.d.ts +18 -9
  16. package/dist/camera/useDeviceOrientation.js +18 -9
  17. package/dist/camera/useOrientationDrift.d.ts +104 -0
  18. package/dist/camera/useOrientationDrift.js +120 -0
  19. package/dist/index.d.ts +5 -0
  20. package/dist/index.js +12 -1
  21. package/dist/stitching/incremental.d.ts +5 -3
  22. package/ios/Sources/RNImageStitcher/ARSessionBridge.swift +7 -1
  23. package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.h +4 -3
  24. package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.mm +9 -7
  25. package/ios/Sources/RNImageStitcher/RNSARSession.swift +46 -7
  26. package/package.json +1 -1
  27. package/src/camera/ARCameraView.tsx +18 -1
  28. package/src/camera/Camera.tsx +639 -21
  29. package/src/camera/OrientationDriftModal.tsx +224 -0
  30. package/src/camera/PanoramaBandOverlay.tsx +135 -49
  31. package/src/camera/PanoramaSettingsModal.tsx +14 -0
  32. package/src/camera/ViewportCropOverlay.tsx +52 -30
  33. package/src/camera/__tests__/useOrientationDrift.test.ts +169 -0
  34. package/src/camera/useDeviceOrientation.ts +18 -9
  35. package/src/camera/useOrientationDrift.ts +172 -0
  36. package/src/index.ts +13 -0
  37. package/src/stitching/incremental.ts +5 -3
@@ -0,0 +1,224 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * OrientationDriftModal — informational popup shown when the SDK
4
+ * auto-abandons an in-progress capture because the device rotated
5
+ * between Mode A (landscape + vertical pan) and Mode B (portrait
6
+ * + horizontal pan) mid-flight.
7
+ *
8
+ * ## When this modal appears
9
+ *
10
+ * In the v0.12 `<Camera>` integration, the modal is rendered while
11
+ * `useOrientationDrift(active).drifted === true`. By the time the
12
+ * modal renders, the capture has ALREADY been stopped (the
13
+ * `<Camera>` component's drift effect calls the engine's `stop()`
14
+ * the same render). The modal exists solely to explain to the
15
+ * user what happened — no "Continue" / "Resume" affordance because
16
+ * the engine docstring at `incremental.ts:373-403` is explicit
17
+ * that cross-mode capture is "best-effort, not supported" and
18
+ * continuing past drift produces malformed output.
19
+ *
20
+ * ## Layer-2 host usage
21
+ *
22
+ * Hosts using `CameraView` directly (rather than the flagship
23
+ * `<Camera>`) can compose this modal with `useOrientationDrift`
24
+ * for the same auto-abandon UX:
25
+ *
26
+ * const drift = useOrientationDrift(captureActive);
27
+ * useEffect(() => {
28
+ * if (drift.drifted) {
29
+ * // host abandons capture (engine stop + state cleanup)
30
+ * stopCapture();
31
+ * }
32
+ * }, [drift.drifted]);
33
+ *
34
+ * return <>
35
+ * <CameraView ... />
36
+ * <OrientationDriftModal
37
+ * visible={drift.drifted}
38
+ * captureOrientation={drift.captureOrientation}
39
+ * currentOrientation={drift.currentOrientation}
40
+ * onAcknowledge={dismissDriftModal}
41
+ * />
42
+ * </>;
43
+ *
44
+ * ## Accessibility
45
+ *
46
+ * Modal `role` defaults to RN's native dialog handling. The OK
47
+ * button carries an `accessibilityRole='button'` + label. Body
48
+ * text uses `accessibilityRole='text'` so the orientation summary
49
+ * is read by VoiceOver / TalkBack.
50
+ */
51
+
52
+ import React from 'react';
53
+ import { Modal, Pressable, StyleSheet, Text, View } from 'react-native';
54
+
55
+ import { type DeviceOrientation } from './useDeviceOrientation';
56
+
57
+
58
+ export interface OrientationDriftModalProps {
59
+ /**
60
+ * Show / hide. In the `<Camera>` integration this is driven by
61
+ * the latched `drifted` flag from `useOrientationDrift`.
62
+ */
63
+ visible: boolean;
64
+
65
+ /**
66
+ * Orientation the capture started in. Shown in the body copy
67
+ * ("Capture started in PORTRAIT") so the user understands the
68
+ * baseline. `undefined` is tolerated (the modal hides the line);
69
+ * the prop is optional only to mirror `useOrientationDrift`'s
70
+ * return shape (which has `undefined` when inactive). When the
71
+ * modal is `visible`, drift detection means this was non-
72
+ * undefined at the moment the flag latched — so undefined here
73
+ * is unlikely in practice.
74
+ */
75
+ captureOrientation: DeviceOrientation | undefined;
76
+
77
+ /**
78
+ * Current device orientation. Shown in the body copy ("now
79
+ * LANDSCAPE-LEFT") so the user understands what changed.
80
+ */
81
+ currentOrientation: DeviceOrientation;
82
+
83
+ /**
84
+ * Tapped when the user dismisses with OK. By the time the
85
+ * modal renders the capture is already stopped; this callback
86
+ * exists only to clear the latched drift state so the next
87
+ * capture can start fresh.
88
+ */
89
+ onAcknowledge: () => void;
90
+ }
91
+
92
+
93
+ /**
94
+ * Pretty-print a `DeviceOrientation` for body copy. Returns the
95
+ * uppercase form because the modal copy reads as "Capture started
96
+ * in PORTRAIT, now LANDSCAPE-LEFT" — uppercase orientations stand
97
+ * out from the surrounding lowercase sentence.
98
+ */
99
+ function formatOrientation(o: DeviceOrientation): string {
100
+ switch (o) {
101
+ case 'portrait':
102
+ return 'PORTRAIT';
103
+ case 'portrait-upside-down':
104
+ return 'PORTRAIT-UPSIDE-DOWN';
105
+ case 'landscape-left':
106
+ return 'LANDSCAPE-LEFT';
107
+ case 'landscape-right':
108
+ return 'LANDSCAPE-RIGHT';
109
+ }
110
+ }
111
+
112
+
113
+ export function OrientationDriftModal(
114
+ props: OrientationDriftModalProps,
115
+ ): React.JSX.Element {
116
+ const { visible, captureOrientation, currentOrientation, onAcknowledge } = props;
117
+
118
+ return (
119
+ <Modal
120
+ visible={visible}
121
+ transparent
122
+ animationType="fade"
123
+ onRequestClose={onAcknowledge}
124
+ accessibilityLabel="Capture cancelled — orientation drift"
125
+ // v0.12.0 — see PanoramaSettingsModal for the same prop's
126
+ // rationale. Declaring all orientations prevents iOS from
127
+ // force-rotating the window to portrait when this modal opens
128
+ // mid-rotation, which would otherwise leave the underlying
129
+ // <Camera>'s ARSession in a stale-orientation state on dismiss.
130
+ supportedOrientations={[
131
+ 'portrait',
132
+ 'portrait-upside-down',
133
+ 'landscape-left',
134
+ 'landscape-right',
135
+ ]}
136
+ >
137
+ <View style={styles.backdrop}>
138
+ <View style={styles.card}>
139
+ <Text style={styles.title} accessibilityRole="header">
140
+ Capture cancelled
141
+ </Text>
142
+
143
+ <Text style={styles.body} accessibilityRole="text">
144
+ Rotation detected mid-capture. Please hold the device
145
+ steady and try again.
146
+ </Text>
147
+
148
+ {captureOrientation !== undefined && (
149
+ <Text style={styles.subBody} accessibilityRole="text">
150
+ Capture started in {formatOrientation(captureOrientation)},
151
+ now {formatOrientation(currentOrientation)}.
152
+ </Text>
153
+ )}
154
+
155
+ <Pressable
156
+ style={({ pressed }) => [
157
+ styles.button,
158
+ pressed && styles.buttonPressed,
159
+ ]}
160
+ onPress={onAcknowledge}
161
+ accessibilityRole="button"
162
+ accessibilityLabel="OK"
163
+ >
164
+ <Text style={styles.buttonLabel}>OK</Text>
165
+ </Pressable>
166
+ </View>
167
+ </View>
168
+ </Modal>
169
+ );
170
+ }
171
+
172
+
173
+ const styles = StyleSheet.create({
174
+ backdrop: {
175
+ flex: 1,
176
+ backgroundColor: 'rgba(0, 0, 0, 0.6)',
177
+ alignItems: 'center',
178
+ justifyContent: 'center',
179
+ paddingHorizontal: 32,
180
+ },
181
+ card: {
182
+ backgroundColor: '#1c1c1e',
183
+ borderRadius: 14,
184
+ paddingHorizontal: 20,
185
+ paddingVertical: 24,
186
+ width: '100%',
187
+ maxWidth: 340,
188
+ },
189
+ title: {
190
+ color: '#fff',
191
+ fontSize: 18,
192
+ fontWeight: '600',
193
+ marginBottom: 12,
194
+ textAlign: 'center',
195
+ },
196
+ body: {
197
+ color: '#e5e5ea',
198
+ fontSize: 15,
199
+ lineHeight: 21,
200
+ textAlign: 'center',
201
+ marginBottom: 12,
202
+ },
203
+ subBody: {
204
+ color: '#8e8e93',
205
+ fontSize: 13,
206
+ lineHeight: 18,
207
+ textAlign: 'center',
208
+ marginBottom: 20,
209
+ },
210
+ button: {
211
+ backgroundColor: '#0a84ff',
212
+ borderRadius: 10,
213
+ paddingVertical: 12,
214
+ alignItems: 'center',
215
+ },
216
+ buttonPressed: {
217
+ backgroundColor: '#0860c0',
218
+ },
219
+ buttonLabel: {
220
+ color: '#fff',
221
+ fontSize: 17,
222
+ fontWeight: '600',
223
+ },
224
+ });
@@ -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
  }