react-native-image-stitcher 0.11.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/README.md +28 -0
  3. package/android/src/main/java/io/imagestitcher/rn/IncrementalFirstwinsEngine.kt +3 -2
  4. package/dist/camera/ARCameraView.d.ts +10 -0
  5. package/dist/camera/ARCameraView.js +1 -0
  6. package/dist/camera/Camera.d.ts +20 -0
  7. package/dist/camera/Camera.js +175 -6
  8. package/dist/camera/OrientationDriftModal.d.ts +83 -0
  9. package/dist/camera/OrientationDriftModal.js +159 -0
  10. package/dist/camera/PanoramaBandOverlay.d.ts +13 -1
  11. package/dist/camera/PanoramaBandOverlay.js +106 -45
  12. package/dist/camera/PanoramaSettingsModal.js +15 -1
  13. package/dist/camera/ViewportCropOverlay.d.ts +35 -31
  14. package/dist/camera/ViewportCropOverlay.js +39 -30
  15. package/dist/camera/useDeviceOrientation.d.ts +18 -9
  16. package/dist/camera/useDeviceOrientation.js +18 -9
  17. package/dist/camera/useOrientationDrift.d.ts +104 -0
  18. package/dist/camera/useOrientationDrift.js +120 -0
  19. package/dist/index.d.ts +5 -0
  20. package/dist/index.js +12 -1
  21. package/dist/stitching/incremental.d.ts +5 -3
  22. package/ios/Sources/RNImageStitcher/ARSessionBridge.swift +7 -1
  23. package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.h +4 -3
  24. package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.mm +9 -7
  25. package/ios/Sources/RNImageStitcher/RNSARSession.swift +46 -7
  26. package/package.json +1 -1
  27. package/src/camera/ARCameraView.tsx +18 -1
  28. package/src/camera/Camera.tsx +280 -13
  29. package/src/camera/OrientationDriftModal.tsx +224 -0
  30. package/src/camera/PanoramaBandOverlay.tsx +135 -49
  31. package/src/camera/PanoramaSettingsModal.tsx +14 -0
  32. package/src/camera/ViewportCropOverlay.tsx +52 -30
  33. package/src/camera/__tests__/useOrientationDrift.test.ts +169 -0
  34. package/src/camera/useDeviceOrientation.ts +18 -9
  35. package/src/camera/useOrientationDrift.ts +172 -0
  36. package/src/index.ts +13 -0
  37. package/src/stitching/incremental.ts +5 -3
package/CHANGELOG.md CHANGED
@@ -16,6 +16,81 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
16
16
 
17
17
  ## [Unreleased]
18
18
 
19
+ ## [0.12.0] — 2026-05-28
20
+
21
+ ### Added — Orientation-aware `<Camera>` (R2-lite)
22
+
23
+ `<Camera>` now works correctly under both portrait-locked and
24
+ non-locked iOS hosts. Pre-v0.12 the component assumed the host
25
+ had restricted `UISupportedInterfaceOrientations` to Portrait;
26
+ removing that restriction broke control layout, camera-preview
27
+ rotation across modal close, and panorama capture mode selection.
28
+
29
+ Five coupled changes:
30
+
31
+ 1. **`useOrientationDrift` hook + `OrientationDriftModal`**
32
+ (PR-1). Snapshots device orientation at capture start and
33
+ latches a `drifted: true` flag if the user rotates mid-
34
+ capture. The incremental engine doesn't support cross-
35
+ orientation captures (per the engine spec at
36
+ `incremental.ts:373-403`), so `<Camera>` auto-cancels via
37
+ `incremental.cancel()` and shows the modal to explain.
38
+
39
+ 2. **New `onCaptureAbandoned` prop** on `<Camera>`. Fires when
40
+ the SDK auto-cancels an in-flight capture. Currently the only
41
+ reason is `'orientation-drift'`; the union signature keeps the
42
+ prop stable for future reasons (low memory, etc.).
43
+
44
+ 3. **4-way home-indicator-edge anchor** for the bottom-controls
45
+ row (Layer A). Combines `useWindowDimensions()` and
46
+ `useDeviceOrientation()` to compute the JS edge that
47
+ corresponds to the device's home-indicator side, then anchors
48
+ shutter / lens / AR toggle there. Matches iOS Camera's
49
+ behaviour: shutter stays within thumb reach regardless of tilt.
50
+
51
+ 4. **AR `takePhoto` orientation parameter** (Fix #2). Pre-v0.12
52
+ `RNSARSession.takePhoto` hardcoded `.right` (90° CW) to
53
+ rotate ARKit's sensor-native landscape buffer to portrait,
54
+ assuming portrait hold. Now switches on the device
55
+ orientation passed from `useDeviceOrientation()` so landscape
56
+ captures produce correctly-oriented photos.
57
+
58
+ 5. **Modal `supportedOrientations={[all 4]}`** on
59
+ `OrientationDriftModal` and `PanoramaSettingsModal`. RN's iOS
60
+ `Modal` defaults to portrait-only, which force-rotates the
61
+ window scene when opened under a non-locked host — leaving
62
+ the underlying `<Camera>`'s ARSession with stale orientation
63
+ state on dismiss (preview rendered sideways). Declaring all
64
+ four orientations keeps the window aligned through the modal
65
+ cycle.
66
+
67
+ ### Added — Comment cleanup across native + JS surfaces
68
+
69
+ Stale "portrait-locked host" comments in
70
+ `useDeviceOrientation.ts`, `incremental.ts`, `StitcherFrame.ts`,
71
+ `OpenCVIncrementalStitcher.{h,mm}`, and
72
+ `IncrementalFirstwinsEngine.kt` rewritten to acknowledge both
73
+ host configurations. Pose-derived orientation detection remains
74
+ the single source of truth — that didn't change; the rationale
75
+ just got more accurate.
76
+
77
+ ### Known follow-ups (deferred to v0.12.1 or v0.13)
78
+
79
+ - Portrait + non-AR stitching can regress under fast horizontal
80
+ pans — likely drift detection over-firing on lateral
81
+ acceleration. Needs debounce or motion-aware threshold.
82
+ - Component-render tests (`<OrientationDriftModal>`,
83
+ `<PanoramaBandOverlay>` per-orientation, `<ViewportCropOverlay>`
84
+ per-orientation, `<Camera>` composition) need
85
+ `@testing-library/react-native` + a jest preset flip. Tracked
86
+ for v0.12.1.
87
+ - Portrait-upside-down landscape detection on non-locked hosts —
88
+ the JS dims signal is ambiguous between locked-portrait + flipped
89
+ device and non-locked + screen-flipped-180°. Needs a separate
90
+ signal.
91
+ - Slot/hybrid API on `<Camera>` to absorb `CaptureControlsBar`,
92
+ `IncrementalPanGuide`, `PanoramaGuidance`, etc. — v0.13.
93
+
19
94
  ## [0.11.1] — 2026-05-28
20
95
 
21
96
  ### Fixed — AR-mode composed worklets silently throw
package/README.md CHANGED
@@ -140,6 +140,34 @@ See `src/camera/Camera.tsx` for the full TSDoc. Highlights:
140
140
  | `onFramesDropped(info)` | cv::Stitcher's confidence retry loop dropped one or more input frames. |
141
141
  | `onError(err)` | Classified error. `err.code` from a known taxonomy (`STITCH_NEED_MORE_IMGS`, `STITCH_HOMOGRAPHY_FAIL`, `STITCH_CAMERA_PARAMS_FAIL`, `STITCH_OOM`, `CAMERA_PERMISSION_DENIED`, etc.). |
142
142
 
143
+ ## Orientation support
144
+
145
+ `<Camera>` works in any device orientation regardless of host
146
+ configuration. No host setup required — the SDK adapts at runtime.
147
+
148
+ **Portrait-locked host** (Info.plist `UISupportedInterfaceOrientations`
149
+ restricted to Portrait — recommended for kiosks / single-task apps):
150
+ the screen stays portrait; the SDK uses sensor-derived orientation
151
+ for capture-mode selection and overlay layout. This is the simpler
152
+ configuration and the historical default.
153
+
154
+ **Non-locked host** (Info.plist supports all 4 orientations — recommended
155
+ for apps with other landscape-friendly screens): the screen rotates
156
+ with the device. `<Camera>`'s controls (shutter, lens chip, AR toggle)
157
+ anchor to the home-indicator edge so they stay within thumb reach
158
+ regardless of tilt — matching iOS Camera's behaviour. The
159
+ orientation-aware logic combines `useWindowDimensions()` (JS-layout)
160
+ with `useDeviceOrientation()` (sensor) to compute the correct anchor.
161
+
162
+ **Mid-capture rotation safety** — the incremental engine doesn't
163
+ support cross-orientation captures (a portrait capture's keyframes
164
+ can't be mixed with landscape-pan frames). If the user rotates
165
+ mid-capture, `<Camera>` auto-abandons via `incremental.cancel()`,
166
+ fires `onCaptureAbandoned('orientation-drift')` if the host wired
167
+ the callback, and shows the `OrientationDriftModal` to explain why.
168
+ Host opt-in via the `onCaptureAbandoned` prop — the default UX is
169
+ the modal alone.
170
+
143
171
  ## Lens ↔ AR interaction
144
172
 
145
173
  | Action | `arPreference` | `lens` | UI |
@@ -38,8 +38,9 @@ import kotlin.math.sqrt
38
38
  * V12.4 — central 70% (pan) × 85% (perpendicular) post-warp crop
39
39
  * V12.6 — orientation detection from R_panToCam at first frame
40
40
  * (NOT from JS-passed frameRotationDegrees, which is wrong
41
- * under iOS interface-orientation lockAndroid equivalent
42
- * is screen-orientation lock; same fix applies)
41
+ * under portrait-locked hostspose-derived detection
42
+ * works regardless of host orientation config, so it's
43
+ * the single source of truth)
43
44
  * V12.7 — rectilinear path: skip cylindrical warp entirely. First
44
45
  * frame pasted raw onto canvas; subsequent frames contribute
45
46
  * a narrow central strip placed by pose-delta around the
@@ -60,6 +60,16 @@ export interface ARCameraViewHandle {
60
60
  */
61
61
  takePhoto: (options?: {
62
62
  quality?: number;
63
+ /**
64
+ * v0.12.0 — device orientation at capture time, used to bake
65
+ * correct rotation into the saved JPEG. Pass the value from
66
+ * `useDeviceOrientation()`. Defaults to `'portrait'` on the
67
+ * native side if omitted (preserves pre-v0.12 behavior).
68
+ * Without this, AR-mode photos taken in landscape come out
69
+ * sideways because the native side previously hardcoded the
70
+ * rotate-to-portrait assumption.
71
+ */
72
+ orientation?: 'portrait' | 'portrait-upside-down' | 'landscape-left' | 'landscape-right';
63
73
  }) => Promise<{
64
74
  path: string;
65
75
  width: number;
@@ -90,6 +90,7 @@ exports.ARCameraView = (0, react_1.forwardRef)(function ARCameraView({ style, gu
90
90
  return native.takePhoto({
91
91
  path: '',
92
92
  quality: options.quality ?? 90,
93
+ orientation: options.orientation ?? 'portrait',
93
94
  });
94
95
  },
95
96
  startRecording: (options) => {
@@ -191,6 +191,26 @@ export interface CameraProps {
191
191
  onLensChange?: (lens: CameraLens) => void;
192
192
  onFramesDropped?: (info: FramesDroppedInfo) => void;
193
193
  onError?: (err: CameraError) => void;
194
+ /**
195
+ * v0.12.0 — fires when the SDK auto-abandons an in-progress
196
+ * capture without producing output. `reason` is a string union
197
+ * so future reasons (network loss, low memory, etc.) can be added
198
+ * without breaking the callback signature.
199
+ *
200
+ * Currently the only reason in v0.12 is `'orientation-drift'`:
201
+ * the user rotated the device between Mode A (landscape + vertical
202
+ * pan) and Mode B (portrait + horizontal pan) mid-capture. The
203
+ * engine docstring at `incremental.ts:373-403` is explicit that
204
+ * cross-mode capture is "best-effort, not supported," so the SDK
205
+ * decisively cancels the capture (`incremental.cancel()`) and
206
+ * surfaces `OrientationDriftModal` to explain what happened.
207
+ *
208
+ * Hosts use this callback to clean up their own state (e.g., reset
209
+ * a wizard step, log telemetry, surface their own retry UX in
210
+ * addition to the SDK's built-in modal). No `onCapture` will fire
211
+ * for an abandoned capture.
212
+ */
213
+ onCaptureAbandoned?: (reason: 'orientation-drift') => void;
194
214
  /**
195
215
  * Optional host-supplied vision-camera frame processor.
196
216
  *
@@ -96,6 +96,8 @@ const buildPanoramaInitialSettings_1 = require("./buildPanoramaInitialSettings")
96
96
  const lowMemDevice_1 = require("./lowMemDevice");
97
97
  const useCapture_1 = require("./useCapture");
98
98
  const useDeviceOrientation_1 = require("./useDeviceOrientation");
99
+ const useOrientationDrift_1 = require("./useOrientationDrift");
100
+ const OrientationDriftModal_1 = require("./OrientationDriftModal");
99
101
  const incremental_1 = require("../stitching/incremental");
100
102
  const useFrameProcessorDriver_1 = require("../stitching/useFrameProcessorDriver");
101
103
  const useIncrementalStitcher_1 = require("../stitching/useIncrementalStitcher");
@@ -270,8 +272,16 @@ function extractPanoramaOverrides(props) {
270
272
  * The public `<Camera>` component.
271
273
  */
272
274
  function Camera(props) {
273
- const { defaultCaptureSource = 'ar', defaultLens = '1x', enablePhotoMode = true, enablePanoramaMode = true, showSettingsButton = false, style, outputDir, onCapture, onCaptureSourceChange, onLensChange, onFramesDropped, onError, frameProcessor: hostFrameProcessor, engine = 'batch-keyframe', } = props;
275
+ const { defaultCaptureSource = 'ar', defaultLens = '1x', enablePhotoMode = true, enablePanoramaMode = true, showSettingsButton = false, style, outputDir, onCapture, onCaptureSourceChange, onLensChange, onFramesDropped, onError, onCaptureAbandoned, frameProcessor: hostFrameProcessor, engine = 'batch-keyframe', } = props;
274
276
  const insets = (0, react_native_safe_area_context_1.useSafeAreaInsets)();
277
+ // v0.12.0 — JS-layout orientation independent of device-physical.
278
+ // `useWindowDimensions().width > height` tells us if the OS
279
+ // rotated the framebuffer (only happens for non-locked hosts in
280
+ // device-landscape). Combined with `useDeviceOrientation()` to
281
+ // pick the JS edge corresponding to the home-indicator side of
282
+ // the device — see `homeIndicatorEdge` below.
283
+ const jsWindow = (0, react_native_1.useWindowDimensions)();
284
+ const jsLandscape = jsWindow.width > jsWindow.height;
275
285
  // ── State ───────────────────────────────────────────────────────
276
286
  const [arPreference, setArPreference] = (0, react_1.useState)(defaultCaptureSource === 'ar');
277
287
  const [lens, setLens] = (0, react_1.useState)(defaultLens);
@@ -435,6 +445,62 @@ function Camera(props) {
435
445
  // Safety: stop the driver if the component unmounts mid-recording.
436
446
  // eslint-disable-next-line react-hooks/exhaustive-deps
437
447
  (0, react_1.useEffect)(() => () => { fpDriver.stop(); }, []);
448
+ // ── v0.12.0 — Orientation drift detection + auto-abandon ────────
449
+ //
450
+ // The incremental engine supports both portrait (Mode B, horizontal
451
+ // pan) and landscape (Mode A, vertical pan) capture as first-class,
452
+ // but the docstring at `incremental.ts:373-403` is explicit that
453
+ // mixing them mid-capture is "best-effort, not supported" — the
454
+ // output rotation becomes ambiguous and the stitched panorama is
455
+ // malformed. v0.12 protects against this by snapshotting the
456
+ // orientation at `start()` and auto-cancelling the capture the
457
+ // instant the user rotates to a different orientation mid-flight.
458
+ //
459
+ // The modal is informational only — by the time it renders, the
460
+ // capture is already stopped. No Continue/Resume affordance per
461
+ // the engine spec.
462
+ const drift = (0, useOrientationDrift_1.useOrientationDrift)(statusPhase === 'recording');
463
+ const [driftModalDismissed, setDriftModalDismissed] = (0, react_1.useState)(false);
464
+ // Reset the dismissed flag when a new capture starts (or any non-
465
+ // recording state) so the next drift event surfaces a fresh modal.
466
+ (0, react_1.useEffect)(() => {
467
+ if (statusPhase !== 'recording')
468
+ setDriftModalDismissed(false);
469
+ }, [statusPhase]);
470
+ (0, react_1.useEffect)(() => {
471
+ if (!drift.drifted || statusPhase !== 'recording')
472
+ return;
473
+ // Auto-abandon the in-flight capture. Order matches handleHoldEnd's
474
+ // "stitch" path but skips finalize:
475
+ // 1. Stop pumping frames so no new keyframes arrive mid-cancel.
476
+ // 2. Tell the native engine to drop accumulated state
477
+ // (`incremental.cancel()`).
478
+ // 3. Reset statusPhase back to idle.
479
+ // 4. Notify the host via `onCaptureAbandoned`.
480
+ //
481
+ // Wrapped in an IIFE because useEffect callbacks can't be async
482
+ // directly. Errors from `incremental.cancel()` are caught + sent
483
+ // through `onError` — abandonment must succeed even if the engine
484
+ // is in a weird state.
485
+ void (async () => {
486
+ fpDriver.stop();
487
+ try {
488
+ await incremental.cancel();
489
+ }
490
+ catch (err) {
491
+ const message = err instanceof Error ? err.message : String(err);
492
+ onError?.(new CameraError('PANORAMA_FINALIZE_FAILED', `cancel after orientation drift failed: ${message}`, err));
493
+ }
494
+ finally {
495
+ setStatusPhase('idle');
496
+ setRecordingStartedAt(null);
497
+ onCaptureAbandoned?.('orientation-drift');
498
+ }
499
+ })();
500
+ // Deps: re-run whenever drift latches OR recording state changes.
501
+ // Other deps are stable refs / setters.
502
+ // eslint-disable-next-line react-hooks/exhaustive-deps
503
+ }, [drift.drifted, statusPhase]);
438
504
  // v0.8.0 Phase 5 / v0.11.0 — frameProcessor prop semantics:
439
505
  //
440
506
  // - Host supplied? → use host's processor. The host's worklet
@@ -564,7 +630,14 @@ function Camera(props) {
564
630
  // ARCameraView writes to its own tmp location; relocate to
565
631
  // photoOutputPath via the native FileBridge so both branches
566
632
  // return paths under the same dir.
567
- const photo = await arViewRef.current.takePhoto({ quality: 90 });
633
+ // v0.12.0 pass deviceOrientation so the AR takePhoto's
634
+ // native CIImage rotation matches the user's view. Pre-
635
+ // v0.12 the native side hardcoded portrait, so landscape
636
+ // photos came out sideways.
637
+ const photo = await arViewRef.current.takePhoto({
638
+ quality: 90,
639
+ orientation: deviceOrientation,
640
+ });
568
641
  try {
569
642
  await (0, files_1.moveFile)(photo.path, photoOutputPath);
570
643
  }
@@ -833,20 +906,116 @@ function Camera(props) {
833
906
  react_1.default.createElement(CaptureDebugOverlay_1.CaptureDebugOverlay, { incrementalState: incrementalState, imuTranslationMetres: isNonAR ? imuGate.getTranslationMetres() : null, captureSource: effectiveCaptureSource, frameSelectionMode: settings.frameSelection.mode, stitchMode: settings.stitcher.stitchMode }))),
834
907
  react_1.default.createElement(CaptureStitchStatsToast_1.CaptureStitchStatsToast, { message: stitchToast.message, topInset: insets.top }),
835
908
  showSettingsButton && (react_1.default.createElement(SettingsButton, { topInset: insets.top, onPress: () => setSettingsModalVisible(true) })),
836
- react_1.default.createElement(react_native_1.View, { pointerEvents: "box-none", style: [styles.bottomArea, { paddingBottom: insets.bottom + 12 }] },
837
- statusPhase === 'recording' && (react_1.default.createElement(PanoramaBandOverlay_1.PanoramaBandOverlay, { state: incrementalState, frameUris: batchKeyframeThumbnails, captureOrientation: deviceOrientation })),
838
- react_1.default.createElement(react_native_1.View, { style: styles.bottomBar },
909
+ react_1.default.createElement(react_native_1.View, { pointerEvents: "box-none", style: bottomAreaStyleForEdge(homeIndicatorEdge(jsLandscape, deviceOrientation), insets.bottom + 12, insets.top + 12) },
910
+ statusPhase === 'recording' && (react_1.default.createElement(PanoramaBandOverlay_1.PanoramaBandOverlay, { state: incrementalState, frameUris: batchKeyframeThumbnails, captureOrientation: deviceOrientation, vertical: isSideEdge(homeIndicatorEdge(jsLandscape, deviceOrientation)) })),
911
+ react_1.default.createElement(react_native_1.View, { style: bottomBarStyleForEdge(homeIndicatorEdge(jsLandscape, deviceOrientation)) },
839
912
  react_1.default.createElement(react_native_1.View, { style: styles.bottomBarLeft }),
840
913
  react_1.default.createElement(react_native_1.View, { style: styles.bottomBarCenter },
841
914
  react_1.default.createElement(LensChip, { lens: lens, onChange: handleLensChange, has0_5x: has0_5x }),
842
915
  react_1.default.createElement(react_native_1.View, { style: styles.shutterWrap },
843
916
  react_1.default.createElement(CameraShutter_1.CameraShutter, { onTap: handleTap, onHoldStart: enablePanoramaMode ? handleHoldStart : noop, onHoldComplete: enablePanoramaMode ? handleHoldEnd : noop, isProcessing: statusPhase === 'stitching', disabled: statusPhase === 'stitching' }))),
844
917
  react_1.default.createElement(react_native_1.View, { style: styles.bottomBarRight }, lens === '1x' && isARSupportedOnDevice && (react_1.default.createElement(ARToggle, { arEnabled: arPreference, onToggle: handleARToggle }))))),
845
- react_1.default.createElement(PanoramaSettingsModal_1.PanoramaSettingsModal, { visible: settingsModalVisible, settings: settings, onChange: setSettings, onClose: () => setSettingsModalVisible(false) })));
918
+ react_1.default.createElement(PanoramaSettingsModal_1.PanoramaSettingsModal, { visible: settingsModalVisible, settings: settings, onChange: setSettings, onClose: () => setSettingsModalVisible(false) }),
919
+ react_1.default.createElement(OrientationDriftModal_1.OrientationDriftModal, { visible: drift.drifted && !driftModalDismissed, captureOrientation: drift.captureOrientation, currentOrientation: drift.currentOrientation, onAcknowledge: () => setDriftModalDismissed(true) })));
846
920
  }
847
921
  function noop() {
848
922
  /* no-op handler used when panorama mode is disabled */
849
923
  }
924
+ function homeIndicatorEdge(jsLandscape, deviceOrient) {
925
+ if (!jsLandscape)
926
+ return 'bottom';
927
+ if (deviceOrient === 'landscape-left')
928
+ return 'right';
929
+ if (deviceOrient === 'landscape-right')
930
+ return 'left';
931
+ return 'right';
932
+ }
933
+ /**
934
+ * v0.12.0 — true when the anchor edge is on a side (left/right), so
935
+ * the band + shutter row need to be vertical strips. Top/bottom
936
+ * anchors yield horizontal strips.
937
+ */
938
+ function isSideEdge(edge) {
939
+ return edge === 'left' || edge === 'right';
940
+ }
941
+ /**
942
+ * v0.12.0 — bottom-controls outer container positioning. Anchors
943
+ * to the home-indicator JS edge with the appropriate flex direction
944
+ * so the band sits on the viewport side of the shutter (toward the
945
+ * camera preview centre).
946
+ */
947
+ function bottomAreaStyleForEdge(edge, bottomInsetPx, topInsetPx) {
948
+ switch (edge) {
949
+ case 'bottom':
950
+ // Band above shutter row, both at JS-bottom. JSX order
951
+ // [band, shutter] + flexDirection 'column' = band at top of
952
+ // stack (closer to screen centre), shutter at JS-bottom.
953
+ return {
954
+ position: 'absolute',
955
+ left: 0,
956
+ right: 0,
957
+ bottom: 0,
958
+ flexDirection: 'column',
959
+ alignItems: 'stretch',
960
+ paddingBottom: bottomInsetPx,
961
+ };
962
+ case 'top':
963
+ // Mirror of bottom. column-reverse so JSX [band, shutter]
964
+ // renders [shutter, band] in JS, shutter at JS-top, band
965
+ // below it (toward screen centre).
966
+ return {
967
+ position: 'absolute',
968
+ left: 0,
969
+ right: 0,
970
+ top: 0,
971
+ flexDirection: 'column-reverse',
972
+ alignItems: 'stretch',
973
+ paddingTop: topInsetPx,
974
+ };
975
+ case 'right':
976
+ // Band to the left of shutter column, both at JS-right.
977
+ // flexDirection 'row' + JSX [band, shutter] = band at JS-left
978
+ // of container (screen centre side), shutter at JS-right.
979
+ return {
980
+ position: 'absolute',
981
+ top: 0,
982
+ bottom: 0,
983
+ right: 0,
984
+ flexDirection: 'row',
985
+ alignItems: 'stretch',
986
+ paddingRight: 12,
987
+ };
988
+ case 'left':
989
+ // Mirror of right. row-reverse so JSX [band, shutter] gives
990
+ // band at JS-right (screen centre side), shutter at JS-left.
991
+ return {
992
+ position: 'absolute',
993
+ top: 0,
994
+ bottom: 0,
995
+ left: 0,
996
+ flexDirection: 'row-reverse',
997
+ alignItems: 'stretch',
998
+ paddingLeft: 12,
999
+ };
1000
+ }
1001
+ }
1002
+ /**
1003
+ * v0.12.0 — inner shutter-row flex direction. Horizontal row for
1004
+ * top/bottom anchors; vertical column for left/right anchors so
1005
+ * the three slots (lens / shutter / AR) stack along the narrow
1006
+ * side strip. Buttons don't rotate — touch targets and text
1007
+ * orient correctly via either (a) un-rotated framebuffer under
1008
+ * portrait-lock or (b) OS-rotated framebuffer under non-locked.
1009
+ */
1010
+ function bottomBarStyleForEdge(edge) {
1011
+ const vertical = isSideEdge(edge);
1012
+ return {
1013
+ flexDirection: vertical ? 'column' : 'row',
1014
+ paddingHorizontal: vertical ? 0 : 18,
1015
+ paddingVertical: vertical ? 18 : 0,
1016
+ alignItems: 'center',
1017
+ };
1018
+ }
850
1019
  const styles = react_native_1.StyleSheet.create({
851
1020
  container: {
852
1021
  flex: 1,
@@ -0,0 +1,83 @@
1
+ /**
2
+ * OrientationDriftModal — informational popup shown when the SDK
3
+ * auto-abandons an in-progress capture because the device rotated
4
+ * between Mode A (landscape + vertical pan) and Mode B (portrait
5
+ * + horizontal pan) mid-flight.
6
+ *
7
+ * ## When this modal appears
8
+ *
9
+ * In the v0.12 `<Camera>` integration, the modal is rendered while
10
+ * `useOrientationDrift(active).drifted === true`. By the time the
11
+ * modal renders, the capture has ALREADY been stopped (the
12
+ * `<Camera>` component's drift effect calls the engine's `stop()`
13
+ * the same render). The modal exists solely to explain to the
14
+ * user what happened — no "Continue" / "Resume" affordance because
15
+ * the engine docstring at `incremental.ts:373-403` is explicit
16
+ * that cross-mode capture is "best-effort, not supported" and
17
+ * continuing past drift produces malformed output.
18
+ *
19
+ * ## Layer-2 host usage
20
+ *
21
+ * Hosts using `CameraView` directly (rather than the flagship
22
+ * `<Camera>`) can compose this modal with `useOrientationDrift`
23
+ * for the same auto-abandon UX:
24
+ *
25
+ * const drift = useOrientationDrift(captureActive);
26
+ * useEffect(() => {
27
+ * if (drift.drifted) {
28
+ * // host abandons capture (engine stop + state cleanup)
29
+ * stopCapture();
30
+ * }
31
+ * }, [drift.drifted]);
32
+ *
33
+ * return <>
34
+ * <CameraView ... />
35
+ * <OrientationDriftModal
36
+ * visible={drift.drifted}
37
+ * captureOrientation={drift.captureOrientation}
38
+ * currentOrientation={drift.currentOrientation}
39
+ * onAcknowledge={dismissDriftModal}
40
+ * />
41
+ * </>;
42
+ *
43
+ * ## Accessibility
44
+ *
45
+ * Modal `role` defaults to RN's native dialog handling. The OK
46
+ * button carries an `accessibilityRole='button'` + label. Body
47
+ * text uses `accessibilityRole='text'` so the orientation summary
48
+ * is read by VoiceOver / TalkBack.
49
+ */
50
+ import React from 'react';
51
+ import { type DeviceOrientation } from './useDeviceOrientation';
52
+ export interface OrientationDriftModalProps {
53
+ /**
54
+ * Show / hide. In the `<Camera>` integration this is driven by
55
+ * the latched `drifted` flag from `useOrientationDrift`.
56
+ */
57
+ visible: boolean;
58
+ /**
59
+ * Orientation the capture started in. Shown in the body copy
60
+ * ("Capture started in PORTRAIT") so the user understands the
61
+ * baseline. `undefined` is tolerated (the modal hides the line);
62
+ * the prop is optional only to mirror `useOrientationDrift`'s
63
+ * return shape (which has `undefined` when inactive). When the
64
+ * modal is `visible`, drift detection means this was non-
65
+ * undefined at the moment the flag latched — so undefined here
66
+ * is unlikely in practice.
67
+ */
68
+ captureOrientation: DeviceOrientation | undefined;
69
+ /**
70
+ * Current device orientation. Shown in the body copy ("now
71
+ * LANDSCAPE-LEFT") so the user understands what changed.
72
+ */
73
+ currentOrientation: DeviceOrientation;
74
+ /**
75
+ * Tapped when the user dismisses with OK. By the time the
76
+ * modal renders the capture is already stopped; this callback
77
+ * exists only to clear the latched drift state so the next
78
+ * capture can start fresh.
79
+ */
80
+ onAcknowledge: () => void;
81
+ }
82
+ export declare function OrientationDriftModal(props: OrientationDriftModalProps): React.JSX.Element;
83
+ //# sourceMappingURL=OrientationDriftModal.d.ts.map
@@ -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