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.
- package/CHANGELOG.md +105 -0
- package/README.md +33 -17
- package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +33 -5
- package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +73 -1
- package/dist/camera/Camera.d.ts +71 -16
- package/dist/camera/Camera.js +167 -51
- package/dist/camera/CameraView.d.ts +6 -0
- package/dist/camera/CameraView.js +2 -2
- package/dist/camera/CaptureHeader.js +39 -16
- package/dist/camera/CapturePreview.js +13 -1
- package/dist/camera/CaptureThumbnailStrip.d.ts +25 -1
- package/dist/camera/CaptureThumbnailStrip.js +17 -4
- package/dist/camera/PanoramaBandOverlay.d.ts +76 -0
- package/dist/camera/PanoramaBandOverlay.js +90 -33
- package/dist/camera/PanoramaConfirmModal.js +11 -1
- package/dist/camera/selectCaptureDevice.d.ts +93 -0
- package/dist/camera/selectCaptureDevice.js +131 -0
- package/dist/camera/useCapture.d.ts +40 -0
- package/dist/camera/useCapture.js +50 -12
- package/dist/camera/useContentRotation.d.ts +99 -0
- package/dist/camera/useContentRotation.js +124 -0
- package/dist/index.d.ts +1 -3
- package/dist/index.js +6 -5
- package/package.json +1 -1
- package/src/camera/Camera.tsx +281 -118
- package/src/camera/CameraView.tsx +9 -0
- package/src/camera/CaptureHeader.tsx +39 -16
- package/src/camera/CapturePreview.tsx +12 -0
- package/src/camera/CaptureThumbnailStrip.tsx +44 -4
- package/src/camera/PanoramaBandOverlay.tsx +97 -35
- package/src/camera/PanoramaConfirmModal.tsx +10 -0
- package/src/camera/__tests__/bandThumbRotation.test.ts +120 -0
- package/src/camera/__tests__/homeIndicatorEdge.test.ts +116 -0
- package/src/camera/__tests__/selectCaptureDevice.test.ts +177 -0
- package/src/camera/__tests__/useContentRotation.test.ts +89 -0
- package/src/camera/selectCaptureDevice.ts +187 -0
- package/src/camera/useCapture.ts +99 -11
- package/src/camera/useContentRotation.ts +149 -0
- package/src/index.ts +6 -2
package/src/camera/Camera.tsx
CHANGED
|
@@ -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
|
1×
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
1572
|
-
//
|
|
1573
|
-
//
|
|
1574
|
-
//
|
|
1575
|
-
//
|
|
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' =
|
|
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.
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
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.1 — the 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
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
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
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
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
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2231
|
+
right: 14,
|
|
2232
|
+
alignItems: 'flex-end',
|
|
2233
|
+
gap: 10,
|
|
2079
2234
|
},
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
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
|
-
|
|
2251
|
+
pillActive: {
|
|
2089
2252
|
backgroundColor: '#ffd34d',
|
|
2090
2253
|
},
|
|
2091
|
-
|
|
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
|