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.
- package/CHANGELOG.md +151 -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 +191 -0
- package/dist/camera/Camera.js +250 -9
- 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 +639 -21
- 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/dist/camera/Camera.js
CHANGED
|
@@ -83,19 +83,26 @@ const useARSession_1 = require("../ar/useARSession");
|
|
|
83
83
|
const ARCameraView_1 = require("./ARCameraView");
|
|
84
84
|
const CameraShutter_1 = require("./CameraShutter");
|
|
85
85
|
const CameraView_1 = require("./CameraView");
|
|
86
|
+
const CaptureHeader_1 = require("./CaptureHeader");
|
|
87
|
+
const CapturePreview_1 = require("./CapturePreview");
|
|
88
|
+
const CaptureThumbnailStrip_1 = require("./CaptureThumbnailStrip");
|
|
86
89
|
const CaptureStatusOverlay_1 = require("./CaptureStatusOverlay");
|
|
87
90
|
const CaptureDebugOverlay_1 = require("./CaptureDebugOverlay");
|
|
88
91
|
const CaptureMemoryPill_1 = require("./CaptureMemoryPill");
|
|
89
92
|
const CaptureKeyframePill_1 = require("./CaptureKeyframePill");
|
|
90
93
|
const CaptureOrientationPill_1 = require("./CaptureOrientationPill");
|
|
91
94
|
const CaptureStitchStatsToast_1 = require("./CaptureStitchStatsToast");
|
|
95
|
+
const IncrementalPanGuide_1 = require("./IncrementalPanGuide");
|
|
92
96
|
const PanoramaBandOverlay_1 = require("./PanoramaBandOverlay");
|
|
97
|
+
const PanoramaGuidance_1 = require("./PanoramaGuidance");
|
|
93
98
|
const PanoramaSettingsBridge_1 = require("./PanoramaSettingsBridge");
|
|
94
99
|
const PanoramaSettingsModal_1 = require("./PanoramaSettingsModal");
|
|
95
100
|
const buildPanoramaInitialSettings_1 = require("./buildPanoramaInitialSettings");
|
|
96
101
|
const lowMemDevice_1 = require("./lowMemDevice");
|
|
97
102
|
const useCapture_1 = require("./useCapture");
|
|
98
103
|
const useDeviceOrientation_1 = require("./useDeviceOrientation");
|
|
104
|
+
const useOrientationDrift_1 = require("./useOrientationDrift");
|
|
105
|
+
const OrientationDriftModal_1 = require("./OrientationDriftModal");
|
|
99
106
|
const incremental_1 = require("../stitching/incremental");
|
|
100
107
|
const useFrameProcessorDriver_1 = require("../stitching/useFrameProcessorDriver");
|
|
101
108
|
const useIncrementalStitcher_1 = require("../stitching/useIncrementalStitcher");
|
|
@@ -270,11 +277,25 @@ function extractPanoramaOverrides(props) {
|
|
|
270
277
|
* The public `<Camera>` component.
|
|
271
278
|
*/
|
|
272
279
|
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;
|
|
280
|
+
const { defaultCaptureSource = 'ar', defaultLens = '1x', enablePhotoMode = true, enablePanoramaMode = true, showSettingsButton = false, style, outputDir, onCapture, onCaptureSourceChange, onLensChange, onFramesDropped, onError, onCaptureAbandoned, flash: controlledFlash, onFlashChange, showFlashButton = true, panGuide = true, panoramaGuidance = true, headerTitle, onHeaderBack, headerBackLabel, headerGuidance, headerColors, thumbnails, thumbnailsMin, thumbnailsMax, onThumbnailPress, capturePreview, capturePreviewActions, onCapturePreviewClose, frameProcessor: hostFrameProcessor, engine = 'batch-keyframe', } = props;
|
|
274
281
|
const insets = (0, react_native_safe_area_context_1.useSafeAreaInsets)();
|
|
282
|
+
// v0.12.0 — JS-layout orientation independent of device-physical.
|
|
283
|
+
// `useWindowDimensions().width > height` tells us if the OS
|
|
284
|
+
// rotated the framebuffer (only happens for non-locked hosts in
|
|
285
|
+
// device-landscape). Combined with `useDeviceOrientation()` to
|
|
286
|
+
// pick the JS edge corresponding to the home-indicator side of
|
|
287
|
+
// the device — see `homeIndicatorEdge` below.
|
|
288
|
+
const jsWindow = (0, react_native_1.useWindowDimensions)();
|
|
289
|
+
const jsLandscape = jsWindow.width > jsWindow.height;
|
|
275
290
|
// ── State ───────────────────────────────────────────────────────
|
|
276
291
|
const [arPreference, setArPreference] = (0, react_1.useState)(defaultCaptureSource === 'ar');
|
|
277
292
|
const [lens, setLens] = (0, react_1.useState)(defaultLens);
|
|
293
|
+
// v0.13.0 — flash state. Controlled by `controlledFlash` when the
|
|
294
|
+
// host supplies the `flash` prop; otherwise owned internally and
|
|
295
|
+
// toggled by the built-in flash button. `effectiveFlash` below
|
|
296
|
+
// also forces 'off' in AR mode (ARKit / ARCore own the device's
|
|
297
|
+
// torch and don't surface it through vision-camera's pipeline).
|
|
298
|
+
const [internalFlash, setInternalFlash] = (0, react_1.useState)('off');
|
|
278
299
|
const [settings, setSettings] = (0, react_1.useState)(() => (0, buildPanoramaInitialSettings_1.buildPanoramaInitialSettings)(extractPanoramaOverrides(props), (0, lowMemDevice_1.isLowMemDevice)()));
|
|
279
300
|
const [settingsModalVisible, setSettingsModalVisible] = (0, react_1.useState)(false);
|
|
280
301
|
const [statusPhase, setStatusPhase] = (0, react_1.useState)('idle');
|
|
@@ -435,6 +456,62 @@ function Camera(props) {
|
|
|
435
456
|
// Safety: stop the driver if the component unmounts mid-recording.
|
|
436
457
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
437
458
|
(0, react_1.useEffect)(() => () => { fpDriver.stop(); }, []);
|
|
459
|
+
// ── v0.12.0 — Orientation drift detection + auto-abandon ────────
|
|
460
|
+
//
|
|
461
|
+
// The incremental engine supports both portrait (Mode B, horizontal
|
|
462
|
+
// pan) and landscape (Mode A, vertical pan) capture as first-class,
|
|
463
|
+
// but the docstring at `incremental.ts:373-403` is explicit that
|
|
464
|
+
// mixing them mid-capture is "best-effort, not supported" — the
|
|
465
|
+
// output rotation becomes ambiguous and the stitched panorama is
|
|
466
|
+
// malformed. v0.12 protects against this by snapshotting the
|
|
467
|
+
// orientation at `start()` and auto-cancelling the capture the
|
|
468
|
+
// instant the user rotates to a different orientation mid-flight.
|
|
469
|
+
//
|
|
470
|
+
// The modal is informational only — by the time it renders, the
|
|
471
|
+
// capture is already stopped. No Continue/Resume affordance per
|
|
472
|
+
// the engine spec.
|
|
473
|
+
const drift = (0, useOrientationDrift_1.useOrientationDrift)(statusPhase === 'recording');
|
|
474
|
+
const [driftModalDismissed, setDriftModalDismissed] = (0, react_1.useState)(false);
|
|
475
|
+
// Reset the dismissed flag when a new capture starts (or any non-
|
|
476
|
+
// recording state) so the next drift event surfaces a fresh modal.
|
|
477
|
+
(0, react_1.useEffect)(() => {
|
|
478
|
+
if (statusPhase !== 'recording')
|
|
479
|
+
setDriftModalDismissed(false);
|
|
480
|
+
}, [statusPhase]);
|
|
481
|
+
(0, react_1.useEffect)(() => {
|
|
482
|
+
if (!drift.drifted || statusPhase !== 'recording')
|
|
483
|
+
return;
|
|
484
|
+
// Auto-abandon the in-flight capture. Order matches handleHoldEnd's
|
|
485
|
+
// "stitch" path but skips finalize:
|
|
486
|
+
// 1. Stop pumping frames so no new keyframes arrive mid-cancel.
|
|
487
|
+
// 2. Tell the native engine to drop accumulated state
|
|
488
|
+
// (`incremental.cancel()`).
|
|
489
|
+
// 3. Reset statusPhase back to idle.
|
|
490
|
+
// 4. Notify the host via `onCaptureAbandoned`.
|
|
491
|
+
//
|
|
492
|
+
// Wrapped in an IIFE because useEffect callbacks can't be async
|
|
493
|
+
// directly. Errors from `incremental.cancel()` are caught + sent
|
|
494
|
+
// through `onError` — abandonment must succeed even if the engine
|
|
495
|
+
// is in a weird state.
|
|
496
|
+
void (async () => {
|
|
497
|
+
fpDriver.stop();
|
|
498
|
+
try {
|
|
499
|
+
await incremental.cancel();
|
|
500
|
+
}
|
|
501
|
+
catch (err) {
|
|
502
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
503
|
+
onError?.(new CameraError('PANORAMA_FINALIZE_FAILED', `cancel after orientation drift failed: ${message}`, err));
|
|
504
|
+
}
|
|
505
|
+
finally {
|
|
506
|
+
setStatusPhase('idle');
|
|
507
|
+
setRecordingStartedAt(null);
|
|
508
|
+
onCaptureAbandoned?.('orientation-drift');
|
|
509
|
+
}
|
|
510
|
+
})();
|
|
511
|
+
// Deps: re-run whenever drift latches OR recording state changes.
|
|
512
|
+
// Other deps are stable refs / setters.
|
|
513
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
514
|
+
}, [drift.drifted, statusPhase]);
|
|
438
515
|
// v0.8.0 Phase 5 / v0.11.0 — frameProcessor prop semantics:
|
|
439
516
|
//
|
|
440
517
|
// - Host supplied? → use host's processor. The host's worklet
|
|
@@ -564,7 +641,14 @@ function Camera(props) {
|
|
|
564
641
|
// ARCameraView writes to its own tmp location; relocate to
|
|
565
642
|
// photoOutputPath via the native FileBridge so both branches
|
|
566
643
|
// return paths under the same dir.
|
|
567
|
-
|
|
644
|
+
// v0.12.0 — pass deviceOrientation so the AR takePhoto's
|
|
645
|
+
// native CIImage rotation matches the user's view. Pre-
|
|
646
|
+
// v0.12 the native side hardcoded portrait, so landscape
|
|
647
|
+
// photos came out sideways.
|
|
648
|
+
const photo = await arViewRef.current.takePhoto({
|
|
649
|
+
quality: 90,
|
|
650
|
+
orientation: deviceOrientation,
|
|
651
|
+
});
|
|
568
652
|
try {
|
|
569
653
|
await (0, files_1.moveFile)(photo.path, photoOutputPath);
|
|
570
654
|
}
|
|
@@ -794,6 +878,22 @@ function Camera(props) {
|
|
|
794
878
|
const handleARToggle = (0, react_1.useCallback)(() => {
|
|
795
879
|
setArPreference((prev) => !prev);
|
|
796
880
|
}, []);
|
|
881
|
+
// ── v0.13.0 — Flash control ─────────────────────────────────────
|
|
882
|
+
//
|
|
883
|
+
// `flashRequested` is what the host / built-in button asks for.
|
|
884
|
+
// `effectiveFlash` is what we actually drive into vision-camera —
|
|
885
|
+
// AR mode forces 'off' because ARKit / ARCore own AVCaptureDevice
|
|
886
|
+
// and the torch isn't exposed. This way the button's visual state
|
|
887
|
+
// (a11y, styling) tracks `flashRequested` while the underlying
|
|
888
|
+
// camera always sees the correct value.
|
|
889
|
+
const flashRequested = controlledFlash ?? internalFlash;
|
|
890
|
+
const effectiveFlash = isAR ? 'off' : flashRequested;
|
|
891
|
+
const toggleFlash = (0, react_1.useCallback)(() => {
|
|
892
|
+
const next = flashRequested === 'on' ? 'off' : 'on';
|
|
893
|
+
if (controlledFlash == null)
|
|
894
|
+
setInternalFlash(next);
|
|
895
|
+
onFlashChange?.(next);
|
|
896
|
+
}, [flashRequested, controlledFlash, onFlashChange]);
|
|
797
897
|
// ── JSX ─────────────────────────────────────────────────────────
|
|
798
898
|
return (react_1.default.createElement(react_native_1.View, { style: [styles.container, style] },
|
|
799
899
|
inFlightTransition ? (react_1.default.createElement(react_native_1.View, { style: [react_native_1.StyleSheet.absoluteFill, styles.transitionPlaceholder] },
|
|
@@ -805,7 +905,7 @@ function Camera(props) {
|
|
|
805
905
|
// the very first buffered preview frame. Android takeSnapshot
|
|
806
906
|
// works either way. Pattern matches AuditCaptureScreen.tsx
|
|
807
907
|
// which has run on `video` (true) for months without issue.
|
|
808
|
-
video: true, flash:
|
|
908
|
+
video: true, flash: effectiveFlash, style: react_native_1.StyleSheet.absoluteFill,
|
|
809
909
|
// F8 (FrameProcessor port) — host-supplied worklet runs on
|
|
810
910
|
// the camera producer thread for every frame. Only wired
|
|
811
911
|
// in non-AR mode; AR mode uses ARCameraView which doesn't
|
|
@@ -826,27 +926,136 @@ function Camera(props) {
|
|
|
826
926
|
onError?.(new CameraError('VISION_CAMERA_RUNTIME', `${codeStr}: ${msg}`, err));
|
|
827
927
|
} })),
|
|
828
928
|
react_1.default.createElement(CaptureStatusOverlay_1.CaptureStatusOverlay, { phase: statusPhase, topInset: insets.top, recordingStartedAt: recordingStartedAt ?? undefined }),
|
|
929
|
+
panGuide && (react_1.default.createElement(IncrementalPanGuide_1.IncrementalPanGuide, { active: statusPhase === 'recording' })),
|
|
930
|
+
panoramaGuidance && (react_1.default.createElement(PanoramaGuidance_1.PanoramaGuidance, { active: statusPhase === 'recording' })),
|
|
829
931
|
settings.debug && (react_1.default.createElement(react_1.default.Fragment, null,
|
|
830
932
|
react_1.default.createElement(CaptureOrientationPill_1.CaptureOrientationPill, { orientation: deviceOrientation, topInset: insets.top }),
|
|
831
933
|
react_1.default.createElement(CaptureKeyframePill_1.CaptureKeyframePill, { state: incrementalState, topInset: insets.top }),
|
|
832
934
|
react_1.default.createElement(CaptureMemoryPill_1.CaptureMemoryPill, { topInset: insets.top }),
|
|
833
935
|
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
936
|
react_1.default.createElement(CaptureStitchStatsToast_1.CaptureStitchStatsToast, { message: stitchToast.message, topInset: insets.top }),
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
937
|
+
headerTitle != null ? (react_1.default.createElement(react_native_1.View, { style: styles.headerWrap, pointerEvents: "box-none" },
|
|
938
|
+
react_1.default.createElement(CaptureHeader_1.CaptureHeader, { title: headerTitle, onBack: onHeaderBack, backLabel: headerBackLabel, guidance: headerGuidance, colors: headerColors, topInset: insets.top, onSettingsPress: showSettingsButton
|
|
939
|
+
? () => setSettingsModalVisible(true)
|
|
940
|
+
: undefined }))) : (showSettingsButton && (react_1.default.createElement(SettingsButton, { topInset: insets.top, onPress: () => setSettingsModalVisible(true) }))),
|
|
941
|
+
thumbnails != null && statusPhase !== 'recording' && (react_1.default.createElement(react_native_1.View, { style: styles.thumbnailStripWrap, pointerEvents: "box-none" },
|
|
942
|
+
react_1.default.createElement(CaptureThumbnailStrip_1.CaptureThumbnailStrip, { items: thumbnails, minPhotos: thumbnailsMin, maxPhotos: thumbnailsMax, onItemPress: onThumbnailPress }))),
|
|
943
|
+
react_1.default.createElement(react_native_1.View, { pointerEvents: "box-none", style: bottomAreaStyleForEdge(homeIndicatorEdge(jsLandscape, deviceOrientation), insets.bottom + 12, insets.top + 12) },
|
|
944
|
+
statusPhase === 'recording' && (react_1.default.createElement(PanoramaBandOverlay_1.PanoramaBandOverlay, { state: incrementalState, frameUris: batchKeyframeThumbnails, captureOrientation: deviceOrientation, vertical: isSideEdge(homeIndicatorEdge(jsLandscape, deviceOrientation)) })),
|
|
945
|
+
react_1.default.createElement(react_native_1.View, { style: bottomBarStyleForEdge(homeIndicatorEdge(jsLandscape, deviceOrientation)) },
|
|
946
|
+
react_1.default.createElement(react_native_1.View, { style: styles.bottomBarLeft }, showFlashButton && (react_1.default.createElement(react_native_1.Pressable, { onPress: isAR ? undefined : toggleFlash, accessibilityRole: "button", accessibilityLabel: isAR ? 'Flash unavailable in AR mode' : `Flash ${flashRequested === 'on' ? 'on' : 'off'}`, accessibilityState: { selected: flashRequested === 'on', disabled: isAR }, disabled: isAR, hitSlop: 8, style: [
|
|
947
|
+
styles.flashButton,
|
|
948
|
+
flashRequested === 'on' && !isAR && styles.flashButtonActive,
|
|
949
|
+
isAR && styles.flashButtonDisabled,
|
|
950
|
+
] },
|
|
951
|
+
react_1.default.createElement(react_native_1.Text, { style: styles.flashIcon }, "\u26A1")))),
|
|
840
952
|
react_1.default.createElement(react_native_1.View, { style: styles.bottomBarCenter },
|
|
841
953
|
react_1.default.createElement(LensChip, { lens: lens, onChange: handleLensChange, has0_5x: has0_5x }),
|
|
842
954
|
react_1.default.createElement(react_native_1.View, { style: styles.shutterWrap },
|
|
843
955
|
react_1.default.createElement(CameraShutter_1.CameraShutter, { onTap: handleTap, onHoldStart: enablePanoramaMode ? handleHoldStart : noop, onHoldComplete: enablePanoramaMode ? handleHoldEnd : noop, isProcessing: statusPhase === 'stitching', disabled: statusPhase === 'stitching' }))),
|
|
844
956
|
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) })
|
|
957
|
+
react_1.default.createElement(PanoramaSettingsModal_1.PanoramaSettingsModal, { visible: settingsModalVisible, settings: settings, onChange: setSettings, onClose: () => setSettingsModalVisible(false) }),
|
|
958
|
+
react_1.default.createElement(OrientationDriftModal_1.OrientationDriftModal, { visible: drift.drifted && !driftModalDismissed, captureOrientation: drift.captureOrientation, currentOrientation: drift.currentOrientation, onAcknowledge: () => setDriftModalDismissed(true) }),
|
|
959
|
+
react_1.default.createElement(CapturePreview_1.CapturePreview, { visible: capturePreview != null, imageUri: capturePreview?.imageUri ?? '', imageWidth: capturePreview?.imageWidth, imageHeight: capturePreview?.imageHeight, title: capturePreview?.title, actions: capturePreviewActions, onClose: onCapturePreviewClose ?? noop })));
|
|
846
960
|
}
|
|
847
961
|
function noop() {
|
|
848
962
|
/* no-op handler used when panorama mode is disabled */
|
|
849
963
|
}
|
|
964
|
+
function homeIndicatorEdge(jsLandscape, deviceOrient) {
|
|
965
|
+
if (!jsLandscape)
|
|
966
|
+
return 'bottom';
|
|
967
|
+
if (deviceOrient === 'landscape-left')
|
|
968
|
+
return 'right';
|
|
969
|
+
if (deviceOrient === 'landscape-right')
|
|
970
|
+
return 'left';
|
|
971
|
+
return 'right';
|
|
972
|
+
}
|
|
973
|
+
/**
|
|
974
|
+
* v0.12.0 — true when the anchor edge is on a side (left/right), so
|
|
975
|
+
* the band + shutter row need to be vertical strips. Top/bottom
|
|
976
|
+
* anchors yield horizontal strips.
|
|
977
|
+
*/
|
|
978
|
+
function isSideEdge(edge) {
|
|
979
|
+
return edge === 'left' || edge === 'right';
|
|
980
|
+
}
|
|
981
|
+
/**
|
|
982
|
+
* v0.12.0 — bottom-controls outer container positioning. Anchors
|
|
983
|
+
* to the home-indicator JS edge with the appropriate flex direction
|
|
984
|
+
* so the band sits on the viewport side of the shutter (toward the
|
|
985
|
+
* camera preview centre).
|
|
986
|
+
*/
|
|
987
|
+
function bottomAreaStyleForEdge(edge, bottomInsetPx, topInsetPx) {
|
|
988
|
+
switch (edge) {
|
|
989
|
+
case 'bottom':
|
|
990
|
+
// Band above shutter row, both at JS-bottom. JSX order
|
|
991
|
+
// [band, shutter] + flexDirection 'column' = band at top of
|
|
992
|
+
// stack (closer to screen centre), shutter at JS-bottom.
|
|
993
|
+
return {
|
|
994
|
+
position: 'absolute',
|
|
995
|
+
left: 0,
|
|
996
|
+
right: 0,
|
|
997
|
+
bottom: 0,
|
|
998
|
+
flexDirection: 'column',
|
|
999
|
+
alignItems: 'stretch',
|
|
1000
|
+
paddingBottom: bottomInsetPx,
|
|
1001
|
+
};
|
|
1002
|
+
case 'top':
|
|
1003
|
+
// Mirror of bottom. column-reverse so JSX [band, shutter]
|
|
1004
|
+
// renders [shutter, band] in JS, shutter at JS-top, band
|
|
1005
|
+
// below it (toward screen centre).
|
|
1006
|
+
return {
|
|
1007
|
+
position: 'absolute',
|
|
1008
|
+
left: 0,
|
|
1009
|
+
right: 0,
|
|
1010
|
+
top: 0,
|
|
1011
|
+
flexDirection: 'column-reverse',
|
|
1012
|
+
alignItems: 'stretch',
|
|
1013
|
+
paddingTop: topInsetPx,
|
|
1014
|
+
};
|
|
1015
|
+
case 'right':
|
|
1016
|
+
// Band to the left of shutter column, both at JS-right.
|
|
1017
|
+
// flexDirection 'row' + JSX [band, shutter] = band at JS-left
|
|
1018
|
+
// of container (screen centre side), shutter at JS-right.
|
|
1019
|
+
return {
|
|
1020
|
+
position: 'absolute',
|
|
1021
|
+
top: 0,
|
|
1022
|
+
bottom: 0,
|
|
1023
|
+
right: 0,
|
|
1024
|
+
flexDirection: 'row',
|
|
1025
|
+
alignItems: 'stretch',
|
|
1026
|
+
paddingRight: 12,
|
|
1027
|
+
};
|
|
1028
|
+
case 'left':
|
|
1029
|
+
// Mirror of right. row-reverse so JSX [band, shutter] gives
|
|
1030
|
+
// band at JS-right (screen centre side), shutter at JS-left.
|
|
1031
|
+
return {
|
|
1032
|
+
position: 'absolute',
|
|
1033
|
+
top: 0,
|
|
1034
|
+
bottom: 0,
|
|
1035
|
+
left: 0,
|
|
1036
|
+
flexDirection: 'row-reverse',
|
|
1037
|
+
alignItems: 'stretch',
|
|
1038
|
+
paddingLeft: 12,
|
|
1039
|
+
};
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
/**
|
|
1043
|
+
* v0.12.0 — inner shutter-row flex direction. Horizontal row for
|
|
1044
|
+
* top/bottom anchors; vertical column for left/right anchors so
|
|
1045
|
+
* the three slots (lens / shutter / AR) stack along the narrow
|
|
1046
|
+
* side strip. Buttons don't rotate — touch targets and text
|
|
1047
|
+
* orient correctly via either (a) un-rotated framebuffer under
|
|
1048
|
+
* portrait-lock or (b) OS-rotated framebuffer under non-locked.
|
|
1049
|
+
*/
|
|
1050
|
+
function bottomBarStyleForEdge(edge) {
|
|
1051
|
+
const vertical = isSideEdge(edge);
|
|
1052
|
+
return {
|
|
1053
|
+
flexDirection: vertical ? 'column' : 'row',
|
|
1054
|
+
paddingHorizontal: vertical ? 0 : 18,
|
|
1055
|
+
paddingVertical: vertical ? 18 : 0,
|
|
1056
|
+
alignItems: 'center',
|
|
1057
|
+
};
|
|
1058
|
+
}
|
|
850
1059
|
const styles = react_native_1.StyleSheet.create({
|
|
851
1060
|
container: {
|
|
852
1061
|
flex: 1,
|
|
@@ -876,6 +1085,8 @@ const styles = react_native_1.StyleSheet.create({
|
|
|
876
1085
|
},
|
|
877
1086
|
bottomBarLeft: {
|
|
878
1087
|
flex: 1,
|
|
1088
|
+
alignItems: 'flex-start',
|
|
1089
|
+
justifyContent: 'flex-end',
|
|
879
1090
|
},
|
|
880
1091
|
bottomBarCenter: {
|
|
881
1092
|
flex: 1,
|
|
@@ -889,5 +1100,35 @@ const styles = react_native_1.StyleSheet.create({
|
|
|
889
1100
|
shutterWrap: {
|
|
890
1101
|
marginTop: 12,
|
|
891
1102
|
},
|
|
1103
|
+
headerWrap: {
|
|
1104
|
+
position: 'absolute',
|
|
1105
|
+
top: 0,
|
|
1106
|
+
left: 0,
|
|
1107
|
+
right: 0,
|
|
1108
|
+
},
|
|
1109
|
+
thumbnailStripWrap: {
|
|
1110
|
+
position: 'absolute',
|
|
1111
|
+
left: 0,
|
|
1112
|
+
right: 0,
|
|
1113
|
+
bottom: 160,
|
|
1114
|
+
},
|
|
1115
|
+
flashButton: {
|
|
1116
|
+
width: 44,
|
|
1117
|
+
height: 44,
|
|
1118
|
+
borderRadius: 22,
|
|
1119
|
+
alignItems: 'center',
|
|
1120
|
+
justifyContent: 'center',
|
|
1121
|
+
backgroundColor: 'rgba(0,0,0,0.45)',
|
|
1122
|
+
},
|
|
1123
|
+
flashButtonActive: {
|
|
1124
|
+
backgroundColor: '#ffd34d',
|
|
1125
|
+
},
|
|
1126
|
+
flashButtonDisabled: {
|
|
1127
|
+
opacity: 0.35,
|
|
1128
|
+
},
|
|
1129
|
+
flashIcon: {
|
|
1130
|
+
fontSize: 20,
|
|
1131
|
+
color: '#ffffff',
|
|
1132
|
+
},
|
|
892
1133
|
});
|
|
893
1134
|
//# sourceMappingURL=Camera.js.map
|
|
@@ -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
|
|
@@ -70,6 +70,18 @@ import type { IncrementalState } from '../stitching/incremental';
|
|
|
70
70
|
*/
|
|
71
71
|
export type BandCaptureOrientation = 'portrait' | 'portrait-upside-down' | 'landscape-left' | 'landscape-right';
|
|
72
72
|
export interface PanoramaBandOverlayProps {
|
|
73
|
+
/**
|
|
74
|
+
* v0.12.0 — `true` when the band should render as a vertical
|
|
75
|
+
* column in JS (anchor edge is JS-left or JS-right, i.e.
|
|
76
|
+
* non-locked host with device-landscape). `false` (default)
|
|
77
|
+
* renders the legacy horizontal strip — covers portrait-locked
|
|
78
|
+
* hosts in any device orientation AND non-locked hosts in
|
|
79
|
+
* portrait. The flagship `<Camera>` derives this from
|
|
80
|
+
* `useWindowDimensions()` + `useDeviceOrientation()` (see
|
|
81
|
+
* `homeIndicatorEdge` in `Camera.tsx`); Layer-2 hosts pass it
|
|
82
|
+
* directly.
|
|
83
|
+
*/
|
|
84
|
+
vertical?: boolean;
|
|
73
85
|
/**
|
|
74
86
|
* Latest engine state. Pass `useIncrementalStitcher().state`.
|
|
75
87
|
* Used for single-thumb fallback URI and fill-ratio when no
|
|
@@ -103,5 +115,5 @@ export interface PanoramaBandOverlayProps {
|
|
|
103
115
|
*/
|
|
104
116
|
captureOrientation?: BandCaptureOrientation;
|
|
105
117
|
}
|
|
106
|
-
export declare function PanoramaBandOverlay({ state, frameUris, captureOrientation, }: PanoramaBandOverlayProps): React.JSX.Element | null;
|
|
118
|
+
export declare function PanoramaBandOverlay({ state, frameUris, captureOrientation, vertical, }: PanoramaBandOverlayProps): React.JSX.Element | null;
|
|
107
119
|
//# sourceMappingURL=PanoramaBandOverlay.d.ts.map
|