react-native-image-stitcher 0.11.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CHANGELOG.md +151 -0
  2. package/README.md +28 -0
  3. package/android/src/main/java/io/imagestitcher/rn/IncrementalFirstwinsEngine.kt +3 -2
  4. package/dist/camera/ARCameraView.d.ts +10 -0
  5. package/dist/camera/ARCameraView.js +1 -0
  6. package/dist/camera/Camera.d.ts +191 -0
  7. package/dist/camera/Camera.js +250 -9
  8. package/dist/camera/OrientationDriftModal.d.ts +83 -0
  9. package/dist/camera/OrientationDriftModal.js +159 -0
  10. package/dist/camera/PanoramaBandOverlay.d.ts +13 -1
  11. package/dist/camera/PanoramaBandOverlay.js +106 -45
  12. package/dist/camera/PanoramaSettingsModal.js +15 -1
  13. package/dist/camera/ViewportCropOverlay.d.ts +35 -31
  14. package/dist/camera/ViewportCropOverlay.js +39 -30
  15. package/dist/camera/useDeviceOrientation.d.ts +18 -9
  16. package/dist/camera/useDeviceOrientation.js +18 -9
  17. package/dist/camera/useOrientationDrift.d.ts +104 -0
  18. package/dist/camera/useOrientationDrift.js +120 -0
  19. package/dist/index.d.ts +5 -0
  20. package/dist/index.js +12 -1
  21. package/dist/stitching/incremental.d.ts +5 -3
  22. package/ios/Sources/RNImageStitcher/ARSessionBridge.swift +7 -1
  23. package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.h +4 -3
  24. package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.mm +9 -7
  25. package/ios/Sources/RNImageStitcher/RNSARSession.swift +46 -7
  26. package/package.json +1 -1
  27. package/src/camera/ARCameraView.tsx +18 -1
  28. package/src/camera/Camera.tsx +639 -21
  29. package/src/camera/OrientationDriftModal.tsx +224 -0
  30. package/src/camera/PanoramaBandOverlay.tsx +135 -49
  31. package/src/camera/PanoramaSettingsModal.tsx +14 -0
  32. package/src/camera/ViewportCropOverlay.tsx +52 -30
  33. package/src/camera/__tests__/useOrientationDrift.test.ts +169 -0
  34. package/src/camera/useDeviceOrientation.ts +18 -9
  35. package/src/camera/useOrientationDrift.ts +172 -0
  36. package/src/index.ts +13 -0
  37. package/src/stitching/incremental.ts +5 -3
@@ -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
- const photo = await arViewRef.current.takePhoto({ quality: 90 });
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: "off", style: react_native_1.StyleSheet.absoluteFill,
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
- 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 },
839
- react_1.default.createElement(react_native_1.View, { style: styles.bottomBarLeft }),
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