react-native-image-stitcher 0.13.0 → 0.14.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 (39) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/README.md +33 -17
  3. package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +33 -5
  4. package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +73 -1
  5. package/dist/camera/Camera.d.ts +71 -16
  6. package/dist/camera/Camera.js +167 -51
  7. package/dist/camera/CameraView.d.ts +6 -0
  8. package/dist/camera/CameraView.js +2 -2
  9. package/dist/camera/CaptureHeader.js +39 -16
  10. package/dist/camera/CapturePreview.js +13 -1
  11. package/dist/camera/CaptureThumbnailStrip.d.ts +25 -1
  12. package/dist/camera/CaptureThumbnailStrip.js +17 -4
  13. package/dist/camera/PanoramaBandOverlay.d.ts +76 -0
  14. package/dist/camera/PanoramaBandOverlay.js +90 -33
  15. package/dist/camera/PanoramaConfirmModal.js +11 -1
  16. package/dist/camera/selectCaptureDevice.d.ts +93 -0
  17. package/dist/camera/selectCaptureDevice.js +131 -0
  18. package/dist/camera/useCapture.d.ts +40 -0
  19. package/dist/camera/useCapture.js +50 -12
  20. package/dist/camera/useContentRotation.d.ts +99 -0
  21. package/dist/camera/useContentRotation.js +124 -0
  22. package/dist/index.d.ts +1 -3
  23. package/dist/index.js +6 -5
  24. package/package.json +1 -1
  25. package/src/camera/Camera.tsx +281 -118
  26. package/src/camera/CameraView.tsx +9 -0
  27. package/src/camera/CaptureHeader.tsx +39 -16
  28. package/src/camera/CapturePreview.tsx +12 -0
  29. package/src/camera/CaptureThumbnailStrip.tsx +44 -4
  30. package/src/camera/PanoramaBandOverlay.tsx +97 -35
  31. package/src/camera/PanoramaConfirmModal.tsx +10 -0
  32. package/src/camera/__tests__/bandThumbRotation.test.ts +120 -0
  33. package/src/camera/__tests__/homeIndicatorEdge.test.ts +116 -0
  34. package/src/camera/__tests__/selectCaptureDevice.test.ts +177 -0
  35. package/src/camera/__tests__/useContentRotation.test.ts +89 -0
  36. package/src/camera/selectCaptureDevice.ts +187 -0
  37. package/src/camera/useCapture.ts +99 -11
  38. package/src/camera/useContentRotation.ts +149 -0
  39. package/src/index.ts +6 -2
@@ -48,6 +48,7 @@ import React, {
48
48
  } from 'react';
49
49
  import {
50
50
  NativeModules,
51
+ Platform,
51
52
  Pressable,
52
53
  StyleSheet,
53
54
  Text,
@@ -79,9 +80,7 @@ import { CaptureMemoryPill } from './CaptureMemoryPill';
79
80
  import { CaptureKeyframePill } from './CaptureKeyframePill';
80
81
  import { CaptureOrientationPill } from './CaptureOrientationPill';
81
82
  import { CaptureStitchStatsToast, useStitchStatsToast } from './CaptureStitchStatsToast';
82
- import { IncrementalPanGuide } from './IncrementalPanGuide';
83
83
  import { PanoramaBandOverlay } from './PanoramaBandOverlay';
84
- import { PanoramaGuidance } from './PanoramaGuidance';
85
84
  import { type PanoramaSettings } from './PanoramaSettings';
86
85
  import { panoramaSettingsToNativeConfig } from './PanoramaSettingsBridge';
87
86
  import { PanoramaSettingsModal } from './PanoramaSettingsModal';
@@ -92,6 +91,7 @@ import {
92
91
  import { isLowMemDevice } from './lowMemDevice';
93
92
  import { useCapture } from './useCapture';
94
93
  import { useDeviceOrientation, type DeviceOrientation } from './useDeviceOrientation';
94
+ import { useContentRotation } from './useContentRotation';
95
95
  import { useOrientationDrift } from './useOrientationDrift';
96
96
  import { OrientationDriftModal } from './OrientationDriftModal';
97
97
  import {
@@ -115,6 +115,17 @@ import {
115
115
  // ─── Types ──────────────────────────────────────────────────────────
116
116
 
117
117
  export type CaptureSource = 'ar' | 'non-ar';
118
+ /**
119
+ * v0.13.2 — which capture sources the host ALLOWS. A constraint on top
120
+ * of `defaultCaptureSource` (which picks the initial source within this
121
+ * constraint):
122
+ * 'both' — AR and non-AR both available; AR toggle is shown.
123
+ * 'ar' — AR only; AR toggle hidden (nothing to switch to), and the
124
+ * 0.5× lens chooser is hidden (ARKit/ARCore don't expose the
125
+ * ultra-wide).
126
+ * 'non-ar' — non-AR only; AR toggle hidden.
127
+ */
128
+ export type CaptureSourcesMode = 'ar' | 'non-ar' | 'both';
118
129
  export type CameraLens = '1x' | '0.5x';
119
130
  export type StitchMode = 'auto' | 'panorama' | 'scans';
120
131
  export type Blender = 'multiband' | 'feather';
@@ -243,6 +254,19 @@ export interface CameraProps {
243
254
  enablePhotoMode?: boolean;
244
255
  enablePanoramaMode?: boolean;
245
256
  showSettingsButton?: boolean;
257
+ /**
258
+ * v0.13.2 — which capture sources the host allows (default `'both'`).
259
+ * Constrains both the runtime AR toggle and `defaultCaptureSource`:
260
+ * - `'both'` : AR + non-AR; the AR toggle is shown so the user can
261
+ * switch at runtime.
262
+ * - `'ar'` : AR only. AR toggle hidden (nothing to toggle); the
263
+ * 0.5× lens chooser is also hidden (ARKit/ARCore can't use the
264
+ * ultra-wide), so the camera stays on the AR-capable 1× lens.
265
+ * - `'non-ar'`: non-AR only. AR toggle hidden.
266
+ * When set to a single source, that source wins regardless of
267
+ * `defaultCaptureSource`.
268
+ */
269
+ captureSources?: CaptureSourcesMode;
246
270
  style?: StyleProp<ViewStyle>;
247
271
 
248
272
  /**
@@ -370,24 +394,6 @@ export interface CameraProps {
370
394
  */
371
395
  showFlashButton?: boolean;
372
396
 
373
- /**
374
- * v0.13.0 — show the built-in IncrementalPanGuide ("keep the
375
- * arrow on the line" drift marker) while recording. Defaults
376
- * to `true`. The guide is gyroscope-driven and only active
377
- * during the recording phase (no idle sensor cost). Hosts that
378
- * want their own pan-guide chrome can opt out via `false`.
379
- */
380
- panGuide?: boolean;
381
-
382
- /**
383
- * v0.13.0 — show the built-in PanoramaGuidance pan-speed pill
384
- * ("Pan slowly" / "Slow down" / "Too fast") while recording.
385
- * Defaults to `true`. Gyroscope-driven, only active during
386
- * recording. Hosts that want their own speed chrome can opt
387
- * out via `false`.
388
- */
389
- panoramaGuidance?: boolean;
390
-
391
397
  /**
392
398
  * v0.13.0 — built-in CaptureHeader title. When set, `<Camera>`
393
399
  * renders a top-of-screen header showing this title (centred)
@@ -615,12 +621,19 @@ interface LensChipProps {
615
621
  lens: CameraLens;
616
622
  onChange: (lens: CameraLens) => void;
617
623
  has0_5x: boolean;
624
+ /**
625
+ * v0.13.1 — counter-rotation applied to the label TEXT (not the pill
626
+ * container) so the "0.5×"/"1×" glyphs read upright when the device
627
+ * is held landscape under a portrait-locked host, while the pill
628
+ * itself stays fixed in the layout. `{}` (no-op) in the upright cases.
629
+ */
630
+ contentRotation?: { transform?: ViewStyle['transform'] };
618
631
  }
619
- function LensChip({ lens, onChange, has0_5x }: LensChipProps): React.JSX.Element {
632
+ function LensChip({ lens, onChange, has0_5x, contentRotation }: LensChipProps): React.JSX.Element {
620
633
  if (!has0_5x) {
621
634
  return (
622
635
  <View style={[lensChipStyles.container, lensChipStyles.singleLens]}>
623
- <Text style={lensChipStyles.label}>1×</Text>
636
+ <Text style={[lensChipStyles.label, contentRotation]}>1×</Text>
624
637
  </View>
625
638
  );
626
639
  }
@@ -640,6 +653,7 @@ function LensChip({ lens, onChange, has0_5x }: LensChipProps): React.JSX.Element
640
653
  style={[
641
654
  lensChipStyles.label,
642
655
  lens === '0.5x' && lensChipStyles.labelActive,
656
+ contentRotation,
643
657
  ]}
644
658
  >
645
659
  0.5×
@@ -659,6 +673,7 @@ function LensChip({ lens, onChange, has0_5x }: LensChipProps): React.JSX.Element
659
673
  style={[
660
674
  lensChipStyles.label,
661
675
  lens === '1x' && lensChipStyles.labelActive,
676
+ contentRotation,
662
677
  ]}
663
678
  >
664
679
 
@@ -708,8 +723,15 @@ const lensChipStyles = StyleSheet.create({
708
723
  interface ARToggleProps {
709
724
  arEnabled: boolean;
710
725
  onToggle: () => void;
726
+ /**
727
+ * v0.13.1 — counter-rotation applied to the "AR" label TEXT (not the
728
+ * pill container) so the glyph reads upright when the device is held
729
+ * landscape under a portrait-locked host, while the pill stays fixed.
730
+ * `{}` no-op in the upright cases.
731
+ */
732
+ contentRotation?: { transform?: ViewStyle['transform'] };
711
733
  }
712
- function ARToggle({ arEnabled, onToggle }: ARToggleProps): React.JSX.Element {
734
+ function ARToggle({ arEnabled, onToggle, contentRotation }: ARToggleProps): React.JSX.Element {
713
735
  return (
714
736
  <Pressable
715
737
  onPress={onToggle}
@@ -722,6 +744,7 @@ function ARToggle({ arEnabled, onToggle }: ARToggleProps): React.JSX.Element {
722
744
  style={[
723
745
  arToggleStyles.label,
724
746
  arEnabled && arToggleStyles.labelOn,
747
+ contentRotation,
725
748
  ]}
726
749
  >
727
750
  AR
@@ -863,6 +886,7 @@ export function Camera(props: CameraProps): React.JSX.Element {
863
886
  const {
864
887
  defaultCaptureSource = 'ar',
865
888
  defaultLens = '1x',
889
+ captureSources = 'both',
866
890
  enablePhotoMode = true,
867
891
  enablePanoramaMode = true,
868
892
  showSettingsButton = false,
@@ -877,8 +901,6 @@ export function Camera(props: CameraProps): React.JSX.Element {
877
901
  flash: controlledFlash,
878
902
  onFlashChange,
879
903
  showFlashButton = true,
880
- panGuide = true,
881
- panoramaGuidance = true,
882
904
  headerTitle,
883
905
  onHeaderBack,
884
906
  headerBackLabel,
@@ -895,6 +917,14 @@ export function Camera(props: CameraProps): React.JSX.Element {
895
917
  engine = 'batch-keyframe',
896
918
  } = props;
897
919
 
920
+ // v0.13.2 — capture-source constraint (default 'both'). Derives which
921
+ // sources are permitted; `captureSources` overrides any conflicting
922
+ // `defaultCaptureSource`. Used to constrain the initial AR preference
923
+ // and to hide the AR toggle / lens chooser below.
924
+ const arAllowed = captureSources !== 'non-ar';
925
+ const nonArAllowed = captureSources !== 'ar';
926
+ const arOnly = captureSources === 'ar';
927
+
898
928
  const insets = useSafeAreaInsets();
899
929
  // v0.12.0 — JS-layout orientation independent of device-physical.
900
930
  // `useWindowDimensions().width > height` tells us if the OS
@@ -906,10 +936,15 @@ export function Camera(props: CameraProps): React.JSX.Element {
906
936
  const jsLandscape = jsWindow.width > jsWindow.height;
907
937
 
908
938
  // ── State ───────────────────────────────────────────────────────
939
+ // v0.13.2 — initial AR preference honours `defaultCaptureSource` but
940
+ // is clamped to the `captureSources` constraint: 'ar' forces on,
941
+ // 'non-ar' forces off, 'both' uses the default.
909
942
  const [arPreference, setArPreference] = useState(
910
- defaultCaptureSource === 'ar',
943
+ !arAllowed ? false : !nonArAllowed ? true : defaultCaptureSource === 'ar',
911
944
  );
912
- const [lens, setLens] = useState<CameraLens>(defaultLens);
945
+ // v0.13.2 — `arOnly` forces the 1× lens (the ultra-wide isn't usable
946
+ // in AR), and the lens chooser is hidden in that mode.
947
+ const [lens, setLens] = useState<CameraLens>(arOnly ? '1x' : defaultLens);
913
948
  // v0.13.0 — flash state. Controlled by `controlledFlash` when the
914
949
  // host supplies the `flash` prop; otherwise owned internally and
915
950
  // toggled by the built-in flash button. `effectiveFlash` below
@@ -955,6 +990,15 @@ export function Camera(props: CameraProps): React.JSX.Element {
955
990
  const isNonAR = !isAR;
956
991
  const deviceOrientation = useDeviceOrientation();
957
992
 
993
+ // v0.13.1 — counter-rotation for control CONTENT (AR toggle, lens
994
+ // pill, flash icon, thumbnails) so their labels read upright relative
995
+ // to gravity when the device is held landscape under a PORTRAIT-LOCKED
996
+ // host (the recommended config — the JS framebuffer stays portrait, so
997
+ // without this the labels render at 90°). Returns `{}` (no-op) in the
998
+ // common upright cases, including non-locked hosts where the OS already
999
+ // rotated the framebuffer. See `useContentRotation` truth table.
1000
+ const contentRotation = useContentRotation();
1001
+
958
1002
  // ── Camera handoff gate ─────────────────────────────────────────
959
1003
  //
960
1004
  // The placeholder rendered while the underlying camera identity
@@ -986,6 +1030,35 @@ export function Camera(props: CameraProps): React.JSX.Element {
986
1030
  || cameraTransitioning;
987
1031
 
988
1032
 
1033
+ // ── v0.13.1 — Android portrait lock ─────────────────────────────
1034
+ //
1035
+ // Android lets a mounted view force its host Activity's orientation,
1036
+ // so `<Camera>` guarantees a portrait capture surface regardless of
1037
+ // the host app's manifest (even a landscape/unlocked host gets a
1038
+ // portrait camera while `<Camera>` is mounted). The lock lives on
1039
+ // the Activity via the native `RNSARSession` module, so it covers
1040
+ // BOTH the AR (ARCore) and non-AR (vision-camera) capture paths.
1041
+ //
1042
+ // iOS is intentionally NOT locked here: iOS supported orientations
1043
+ // are a static Info.plist declaration the host owns, and we want iOS
1044
+ // hosts to be able to support landscape/unlocked capture. Hosts that
1045
+ // want a portrait-only iOS app set UISupportedInterfaceOrientations
1046
+ // themselves.
1047
+ //
1048
+ // Empty dep array — lock on mount, restore the host's PRIOR
1049
+ // orientation on unmount (the native side captures it).
1050
+ useEffect(() => {
1051
+ if (Platform.OS !== 'android') return undefined;
1052
+ const arModule = (NativeModules as Record<string, unknown>)
1053
+ .RNSARSession as
1054
+ | { lockPortrait?: () => void; unlockOrientation?: () => void }
1055
+ | undefined;
1056
+ arModule?.lockPortrait?.();
1057
+ return () => {
1058
+ arModule?.unlockOrientation?.();
1059
+ };
1060
+ }, []);
1061
+
989
1062
  // ── Notify parent of capture-source changes ─────────────────────
990
1063
  const lastEmittedSourceRef = useRef<CaptureSource | null>(null);
991
1064
  useEffect(() => {
@@ -995,21 +1068,23 @@ export function Camera(props: CameraProps): React.JSX.Element {
995
1068
  }
996
1069
  }, [effectiveCaptureSource, onCaptureSourceChange]);
997
1070
 
998
- // ── Lens chip availability ──────────────────────────────────────
999
- // TODO follow-up: probe the device's available physical lenses via
1000
- // vision-camera's `useCameraDevices` and surface in
1001
- // `useCapture().availablePhysicalDevices`. For now we assume the
1002
- // 0.5x ultra-wide exists on modern devices. When it doesn't, the
1003
- // lens chip degenerates to a static 1× indicator (see LensChip).
1004
- const has0_5x = true;
1005
-
1006
1071
  // ── Capture hooks ───────────────────────────────────────────────
1072
+ // v0.13.2 — pass the active `lens` so useCapture uses capability-aware
1073
+ // selection (multi-cam zoom-switch where available, standalone-ultra-
1074
+ // wide swap otherwise). Replaces the old per-lens
1075
+ // `preferredPhysicalDevice` request that mis-selected on some phones.
1007
1076
  const capture = useCapture({
1008
1077
  cameraPosition: 'back',
1009
1078
  enableQualityChecks: false,
1010
- preferredPhysicalDevice:
1011
- lens === '0.5x' ? 'ultra-wide-angle-camera' : 'wide-angle-camera',
1079
+ lens,
1012
1080
  });
1081
+
1082
+ // ── Lens chip availability ──────────────────────────────────────
1083
+ // v0.13.2 — real device capability from `useCapture` (which uses
1084
+ // `selectCaptureDevice`). True only when the device actually exposes
1085
+ // an ultra-wide reachable via a multi-cam zoom OR a standalone
1086
+ // ultra-wide device; false on wide-only hardware (chip hides).
1087
+ const has0_5x = capture.has0_5x;
1013
1088
  const incremental = useIncrementalStitcher();
1014
1089
  const visionCameraRef = useRef<VisionCamera | null>(null);
1015
1090
  const arViewRef = useRef<ARCameraViewHandle | null>(null);
@@ -1568,19 +1643,44 @@ export function Camera(props: CameraProps): React.JSX.Element {
1568
1643
  // ── v0.13.0 — Flash control ─────────────────────────────────────
1569
1644
  //
1570
1645
  // `flashRequested` is what the host / built-in button asks for.
1571
- // `effectiveFlash` is what we actually drive into vision-camera
1572
- // AR mode forces 'off' because ARKit / ARCore own AVCaptureDevice
1573
- // and the torch isn't exposed. This way the button's visual state
1574
- // (a11y, styling) tracks `flashRequested` while the underlying
1575
- // camera always sees the correct value.
1646
+ // `effectiveFlash` is what we drive into vision-camera (non-AR). AR
1647
+ // mode forces 'off' (flash is hidden in AR; ARKit/ARCore own the
1648
+ // device) so vision-camera — which isn't the active camera in AR
1649
+ // doesn't fight for it.
1650
+ //
1651
+ // v0.13.1 — the ACTIVE device's torch capability is the source of
1652
+ // truth. The ultra-wide (0.5×) lens has no flash/torch unit on most
1653
+ // phones, so vision-camera throws `flash-not-available` if we pass
1654
+ // flash="on" while it's selected. `capture.device.hasTorch` (from
1655
+ // vision-camera's device list) tells us definitively; we hide the
1656
+ // flash control and force 'off' when the device can't flash.
1657
+ // v0.13.2 — `capture.deviceHasTorch` reflects the MOUNTED device. In
1658
+ // multi-cam mode this is the multi-cam device (has a torch → flash
1659
+ // works on both 1× and 0.5× via zoom). In standalone-uw mode on 0.5×
1660
+ // the mounted device is the torchless ultra-wide → flash hides.
1661
+ const deviceHasTorch = capture.deviceHasTorch;
1576
1662
  const flashRequested: 'on' | 'off' = controlledFlash ?? internalFlash;
1577
- const effectiveFlash: 'on' | 'off' = isAR ? 'off' : flashRequested;
1663
+ const effectiveFlash: 'on' | 'off' =
1664
+ isAR || !deviceHasTorch ? 'off' : flashRequested;
1578
1665
  const toggleFlash = useCallback(() => {
1579
1666
  const next: 'on' | 'off' = flashRequested === 'on' ? 'off' : 'on';
1580
1667
  if (controlledFlash == null) setInternalFlash(next);
1581
1668
  onFlashChange?.(next);
1582
1669
  }, [flashRequested, controlledFlash, onFlashChange]);
1583
1670
 
1671
+ // v0.13.1 — top-right control pills (flash + AR) stack vertically
1672
+ // UNDER the settings affordance. Anchor depends on what's above:
1673
+ // - headerTitle set → pills clear the CaptureHeader bar
1674
+ // (title row ≈ topInset + ~36; guidance pill adds ~28 when present)
1675
+ // - standalone gear → pills clear the 40px gear at topInset + 8
1676
+ // - neither → pills start where the gear would be
1677
+ const pillStackTop =
1678
+ headerTitle != null
1679
+ ? insets.top + (headerGuidance != null ? 72 : 40)
1680
+ : showSettingsButton
1681
+ ? insets.top + 8 + 44
1682
+ : insets.top + 8;
1683
+
1584
1684
  // ── JSX ─────────────────────────────────────────────────────────
1585
1685
 
1586
1686
  return (
@@ -1613,6 +1713,11 @@ export function Camera(props: CameraProps): React.JSX.Element {
1613
1713
  // which has run on `video` (true) for months without issue.
1614
1714
  video
1615
1715
  flash={effectiveFlash}
1716
+ // v0.13.2 — in multi-cam mode the lens is switched via zoom
1717
+ // on a single mounted device (0.5× → ultra-wide end, 1× →
1718
+ // wide baseline). undefined in standalone/wide-only modes
1719
+ // (lens = device identity, no zoom).
1720
+ zoom={capture.deviceZoom}
1616
1721
  style={StyleSheet.absoluteFill}
1617
1722
  // F8 (FrameProcessor port) — host-supplied worklet runs on
1618
1723
  // the camera producer thread for every frame. Only wired
@@ -1648,19 +1753,12 @@ export function Camera(props: CameraProps): React.JSX.Element {
1648
1753
  recordingStartedAt={recordingStartedAt ?? undefined}
1649
1754
  />
1650
1755
 
1651
- {/* v0.13.0 — built-in pan guidance overlays. Both sit on top
1652
- of the camera preview but under the controls. Each is
1653
- gyroscope-driven and only subscribes while `active` is
1654
- true flipping `active` false on capture-end tears the
1655
- subscription down so the sensor isn't running idle. Hosts
1656
- can opt out per overlay via the `panGuide` / `panoramaGuidance`
1657
- boolean props (both default true). */}
1658
- {panGuide && (
1659
- <IncrementalPanGuide active={statusPhase === 'recording'} />
1660
- )}
1661
- {panoramaGuidance && (
1662
- <PanoramaGuidance active={statusPhase === 'recording'} />
1663
- )}
1756
+ {/* v0.13.1the built-in pan-guidance overlays
1757
+ (IncrementalPanGuide drift marker + PanoramaGuidance speed
1758
+ pill) were removed from the public surface. They remain in
1759
+ the tree as internal-only components but <Camera> no longer
1760
+ renders them and the `panGuide` / `panoramaGuidance` props
1761
+ are gone. Re-wire here if a host need resurfaces. */}
1664
1762
 
1665
1763
  {/*
1666
1764
  2026-05-22 (audit F9 + F3) — debug UI suite, all gated on
@@ -1735,24 +1833,6 @@ export function Camera(props: CameraProps): React.JSX.Element {
1735
1833
  )
1736
1834
  )}
1737
1835
 
1738
- {/* v0.13.0 — built-in capture-history thumbnail strip. Renders
1739
- when the host supplies a `thumbnails` array (even empty),
1740
- hidden during recording so it doesn't overlap the band
1741
- overlay. Sits above the bottom controls in JS-bottom
1742
- coordinates; landscape/non-locked layouts get the strip in
1743
- the same place (no orientation-aware repositioning for now —
1744
- the strip is intrinsically horizontal). */}
1745
- {thumbnails != null && statusPhase !== 'recording' && (
1746
- <View style={styles.thumbnailStripWrap} pointerEvents="box-none">
1747
- <CaptureThumbnailStrip
1748
- items={thumbnails}
1749
- minPhotos={thumbnailsMin}
1750
- maxPhotos={thumbnailsMax}
1751
- onItemPress={onThumbnailPress}
1752
- />
1753
- </View>
1754
- )}
1755
-
1756
1836
  {/*
1757
1837
  v0.12.0 — Orientation-aware bottom controls anchored to the
1758
1838
  physical home-indicator edge. The shutter follows the home-
@@ -1786,36 +1866,52 @@ export function Camera(props: CameraProps): React.JSX.Element {
1786
1866
  />
1787
1867
  )}
1788
1868
 
1869
+ {/* v0.13.0 — built-in capture-history thumbnail strip. Lives
1870
+ INSIDE the orientation-aware bottomArea container so it
1871
+ rides along to the home-indicator edge in landscape rather
1872
+ than sitting at a hard-coded `bottom: 160` mid-screen.
1873
+ Hidden during recording so the PanoramaBandOverlay above
1874
+ it has room without overlap. Strip is intrinsically
1875
+ horizontal; v0.13.1 will add orientation-aware rotation
1876
+ for the thumbnails + tablet "user-bottom" placement. */}
1877
+ {thumbnails != null && statusPhase !== 'recording' && (
1878
+ <CaptureThumbnailStrip
1879
+ items={thumbnails}
1880
+ minPhotos={thumbnailsMin}
1881
+ maxPhotos={thumbnailsMax}
1882
+ onItemPress={onThumbnailPress}
1883
+ // v0.13.1 — stack the idle strip vertically when the
1884
+ // home-indicator anchor is on a side edge (non-locked host
1885
+ // in landscape), matching PanoramaBandOverlay's `vertical`
1886
+ // so the strip rides the home-indicator edge instead of
1887
+ // running horizontally across the rotated screen.
1888
+ vertical={isSideEdge(homeIndicatorEdge(jsLandscape, deviceOrientation))}
1889
+ // v0.13.1 — counter-rotate the thumbnail images so the
1890
+ // captured scene reads upright in portrait-locked landscape.
1891
+ contentRotation={contentRotation}
1892
+ />
1893
+ )}
1894
+
1789
1895
  {/* Shutter row. Horizontal row when home-indicator is on
1790
1896
  top/bottom (lens left / shutter center / AR right);
1791
1897
  vertical column when on left/right (slots stack along
1792
1898
  the narrow strip). Touch targets stay axis-aligned. */}
1793
1899
  <View style={bottomBarStyleForEdge(homeIndicatorEdge(jsLandscape, deviceOrientation))}>
1794
- <View style={styles.bottomBarLeft}>
1795
- {showFlashButton && (
1796
- <Pressable
1797
- onPress={isAR ? undefined : toggleFlash}
1798
- accessibilityRole="button"
1799
- accessibilityLabel={isAR ? 'Flash unavailable in AR mode' : `Flash ${flashRequested === 'on' ? 'on' : 'off'}`}
1800
- accessibilityState={{ selected: flashRequested === 'on', disabled: isAR }}
1801
- disabled={isAR}
1802
- hitSlop={8}
1803
- style={[
1804
- styles.flashButton,
1805
- flashRequested === 'on' && !isAR && styles.flashButtonActive,
1806
- isAR && styles.flashButtonDisabled,
1807
- ]}
1808
- >
1809
- <Text style={styles.flashIcon}>⚡</Text>
1810
- </Pressable>
1811
- )}
1812
- </View>
1900
+ {/* v0.13.1 — flash + AR moved to the top-right pill stack (see
1901
+ below). Left/right slots stay as flex spacers so the shutter
1902
+ + lens chip remain centred. */}
1903
+ <View style={styles.bottomBarLeft} />
1813
1904
  <View style={styles.bottomBarCenter}>
1814
- <LensChip
1815
- lens={lens}
1816
- onChange={handleLensChange}
1817
- has0_5x={has0_5x}
1818
- />
1905
+ {/* v0.13.2 — lens chooser hidden in AR-only mode (ARKit/ARCore
1906
+ can't use the ultra-wide, so there's nothing to choose). */}
1907
+ {!arOnly && (
1908
+ <LensChip
1909
+ lens={lens}
1910
+ onChange={handleLensChange}
1911
+ has0_5x={has0_5x}
1912
+ contentRotation={contentRotation}
1913
+ />
1914
+ )}
1819
1915
  <View style={styles.shutterWrap}>
1820
1916
  <CameraShutter
1821
1917
  onTap={handleTap}
@@ -1826,14 +1922,53 @@ export function Camera(props: CameraProps): React.JSX.Element {
1826
1922
  />
1827
1923
  </View>
1828
1924
  </View>
1829
- <View style={styles.bottomBarRight}>
1830
- {lens === '1x' && isARSupportedOnDevice && (
1831
- <ARToggle arEnabled={arPreference} onToggle={handleARToggle} />
1832
- )}
1833
- </View>
1925
+ <View style={styles.bottomBarRight} />
1834
1926
  </View>
1835
1927
  </View>
1836
1928
 
1929
+ {/* v0.13.1 — top-right control pill stack, anchored UNDER the
1930
+ settings affordance. Vertical column; pills match the AR
1931
+ toggle's shape. ORDER MATTERS: AR pill is FIRST (top) so it
1932
+ stays anchored when the flash pill below it shows/hides
1933
+ (flash is hidden in AR mode, and when the active device has no
1934
+ torch — e.g. the ultra-wide 0.5× lens). AR toggle shows only
1935
+ when the lens is 1× (ARKit/ARCore don't expose the ultra-wide)
1936
+ and the device supports AR. */}
1937
+ <View
1938
+ style={[styles.pillStack, { top: pillStackTop }]}
1939
+ pointerEvents="box-none"
1940
+ >
1941
+ {/* v0.13.2 — AR toggle only when BOTH sources are allowed
1942
+ (captureSources='both'); a single-source constraint has
1943
+ nothing to toggle. Still gated on 1× + device AR support. */}
1944
+ {arAllowed && nonArAllowed && lens === '1x' && isARSupportedOnDevice && (
1945
+ <ARToggle arEnabled={arPreference} onToggle={handleARToggle} contentRotation={contentRotation} />
1946
+ )}
1947
+ {showFlashButton && !isAR && deviceHasTorch && (
1948
+ <Pressable
1949
+ onPress={toggleFlash}
1950
+ accessibilityRole="button"
1951
+ accessibilityLabel={`Flash ${flashRequested === 'on' ? 'on' : 'off'}`}
1952
+ accessibilityState={{ selected: flashRequested === 'on' }}
1953
+ hitSlop={8}
1954
+ style={[
1955
+ pillStyles.pill,
1956
+ flashRequested === 'on' && pillStyles.pillActive,
1957
+ ]}
1958
+ >
1959
+ <Text
1960
+ style={[
1961
+ pillStyles.flashGlyph,
1962
+ flashRequested === 'on' && pillStyles.glyphActive,
1963
+ contentRotation,
1964
+ ]}
1965
+ >
1966
+
1967
+ </Text>
1968
+ </Pressable>
1969
+ )}
1970
+ </View>
1971
+
1837
1972
  {/* Settings modal (rendered always, visible-gated). */}
1838
1973
  <PanoramaSettingsModal
1839
1974
  visible={settingsModalVisible}
@@ -1934,6 +2069,17 @@ function isSideEdge(edge: HomeIndicatorEdge): boolean {
1934
2069
  return edge === 'left' || edge === 'right';
1935
2070
  }
1936
2071
 
2072
+ // v0.13.1 — test-only exports of the pure orientation-decision
2073
+ // functions. `homeIndicatorEdge` + `isSideEdge` together produce the
2074
+ // `vertical` flag that drives PanoramaBandOverlay and
2075
+ // CaptureThumbnailStrip layout, so they carry the orientation contract.
2076
+ // Unit-tested via these handles (the lib's jest config is pure-TS and
2077
+ // can't mount <Camera>; see jest.config.js).
2078
+ /** @internal test-only — see `homeIndicatorEdge`. */
2079
+ export const _homeIndicatorEdgeForTests = homeIndicatorEdge;
2080
+ /** @internal test-only — see `isSideEdge`. */
2081
+ export const _isSideEdgeForTests = isSideEdge;
2082
+
1937
2083
 
1938
2084
  /**
1939
2085
  * v0.12.0 — bottom-controls outer container positioning. Anchors
@@ -2071,28 +2217,45 @@ const styles = StyleSheet.create({
2071
2217
  left: 0,
2072
2218
  right: 0,
2073
2219
  },
2074
- thumbnailStripWrap: {
2220
+ // v0.13.1 — `thumbnailStripWrap` removed. The strip now renders
2221
+ // inside the orientation-aware bottomArea container (alongside
2222
+ // PanoramaBandOverlay and the bottom bar) rather than as a
2223
+ // position-absolute overlay at hard-coded `bottom: 160`.
2224
+ //
2225
+ // v0.13.1 — top-right control pill stack (flash + AR). Absolute,
2226
+ // pinned to the right edge under the settings affordance; `top` is
2227
+ // set inline from `pillStackTop`. Column so the pills stack
2228
+ // vertically; gap keeps them from touching.
2229
+ pillStack: {
2075
2230
  position: 'absolute',
2076
- left: 0,
2077
- right: 0,
2078
- bottom: 160,
2231
+ right: 14,
2232
+ alignItems: 'flex-end',
2233
+ gap: 10,
2079
2234
  },
2080
- flashButton: {
2081
- width: 44,
2082
- height: 44,
2083
- borderRadius: 22,
2235
+ });
2236
+
2237
+
2238
+ // v0.13.1 — shared pill style for the top-right control stack. The
2239
+ // flash pill matches the AR toggle's shape (same padding / radius /
2240
+ // background) so the two read as a set.
2241
+ const pillStyles = StyleSheet.create({
2242
+ pill: {
2243
+ paddingHorizontal: 14,
2244
+ paddingVertical: 8,
2245
+ borderRadius: 16,
2246
+ backgroundColor: 'rgba(0,0,0,0.45)',
2247
+ minWidth: 56,
2084
2248
  alignItems: 'center',
2085
2249
  justifyContent: 'center',
2086
- backgroundColor: 'rgba(0,0,0,0.45)',
2087
2250
  },
2088
- flashButtonActive: {
2251
+ pillActive: {
2089
2252
  backgroundColor: '#ffd34d',
2090
2253
  },
2091
- flashButtonDisabled: {
2092
- opacity: 0.35,
2093
- },
2094
- flashIcon: {
2095
- fontSize: 20,
2254
+ flashGlyph: {
2096
2255
  color: '#ffffff',
2256
+ fontSize: 18,
2257
+ },
2258
+ glyphActive: {
2259
+ color: '#1a1a1a',
2097
2260
  },
2098
2261
  });
@@ -33,6 +33,12 @@ export interface CameraViewProps {
33
33
  device: CameraDevice | null | undefined;
34
34
  /** Flash / torch state from ``useCapture().flash``. */
35
35
  flash?: 'off' | 'on';
36
+ /**
37
+ * v0.13.2 — zoom factor for the mounted device. Used in multi-cam
38
+ * mode to switch lenses (0.5× ultra-wide ↔ 1× wide) on a single
39
+ * device. `undefined` leaves vision-camera at its default zoom.
40
+ */
41
+ zoom?: number;
36
42
  /** Whether the preview is actively rendering. Defaults to true. */
37
43
  isActive?: boolean;
38
44
  /**
@@ -103,6 +109,7 @@ export const CameraView = forwardRef<Camera | null, CameraViewProps>(function Ca
103
109
  {
104
110
  device,
105
111
  flash = 'off',
112
+ zoom,
106
113
  isActive = true,
107
114
  video = false,
108
115
  guidance,
@@ -150,6 +157,8 @@ export const CameraView = forwardRef<Camera | null, CameraViewProps>(function Ca
150
157
  isActive={isActive}
151
158
  photo
152
159
  video={video}
160
+ // v0.13.2 — multi-cam lens switch via zoom (undefined = default).
161
+ {...(zoom != null ? { zoom } : {})}
153
162
  // Bake the device orientation into the captured pixels.
154
163
  // Without this, vision-camera writes the file in the camera
155
164
  // sensor's native landscape and relies on EXIF metadata to