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.
- package/CHANGELOG.md +75 -0
- package/README.md +28 -0
- package/android/src/main/java/io/imagestitcher/rn/IncrementalFirstwinsEngine.kt +3 -2
- package/dist/camera/ARCameraView.d.ts +10 -0
- package/dist/camera/ARCameraView.js +1 -0
- package/dist/camera/Camera.d.ts +20 -0
- package/dist/camera/Camera.js +175 -6
- package/dist/camera/OrientationDriftModal.d.ts +83 -0
- package/dist/camera/OrientationDriftModal.js +159 -0
- package/dist/camera/PanoramaBandOverlay.d.ts +13 -1
- package/dist/camera/PanoramaBandOverlay.js +106 -45
- package/dist/camera/PanoramaSettingsModal.js +15 -1
- package/dist/camera/ViewportCropOverlay.d.ts +35 -31
- package/dist/camera/ViewportCropOverlay.js +39 -30
- package/dist/camera/useDeviceOrientation.d.ts +18 -9
- package/dist/camera/useDeviceOrientation.js +18 -9
- package/dist/camera/useOrientationDrift.d.ts +104 -0
- package/dist/camera/useOrientationDrift.js +120 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +12 -1
- package/dist/stitching/incremental.d.ts +5 -3
- package/ios/Sources/RNImageStitcher/ARSessionBridge.swift +7 -1
- package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.h +4 -3
- package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.mm +9 -7
- package/ios/Sources/RNImageStitcher/RNSARSession.swift +46 -7
- package/package.json +1 -1
- package/src/camera/ARCameraView.tsx +18 -1
- package/src/camera/Camera.tsx +280 -13
- package/src/camera/OrientationDriftModal.tsx +224 -0
- package/src/camera/PanoramaBandOverlay.tsx +135 -49
- package/src/camera/PanoramaSettingsModal.tsx +14 -0
- package/src/camera/ViewportCropOverlay.tsx +52 -30
- package/src/camera/__tests__/useOrientationDrift.test.ts +169 -0
- package/src/camera/useDeviceOrientation.ts +18 -9
- package/src/camera/useOrientationDrift.ts +172 -0
- package/src/index.ts +13 -0
- 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
|
|
42
|
-
*
|
|
41
|
+
* under portrait-locked hosts — pose-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;
|
package/dist/camera/Camera.d.ts
CHANGED
|
@@ -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
|
*
|
package/dist/camera/Camera.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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:
|
|
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
|