react-native-image-stitcher 0.12.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 +181 -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 +226 -0
- package/dist/camera/Camera.js +208 -20
- 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 +546 -32
- 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
|
@@ -27,20 +27,26 @@ exports.CaptureHeader = CaptureHeader;
|
|
|
27
27
|
const react_1 = __importDefault(require("react"));
|
|
28
28
|
const react_native_1 = require("react-native");
|
|
29
29
|
function CaptureHeader({ title, onBack, backLabel = '‹ Back', onSettingsPress, guidance, topInset = 0, colors, style, }) {
|
|
30
|
-
|
|
30
|
+
// v0.13.1 — defaults are now transparent over the camera preview
|
|
31
|
+
// (matches the AR toggle / settings gear pill style); hosts using
|
|
32
|
+
// the header outside a camera context can pass solid colours via
|
|
33
|
+
// `colors`. Title + gear get a text shadow for legibility over
|
|
34
|
+
// bright preview content; guidance row keeps a translucent pill
|
|
35
|
+
// background for the same reason.
|
|
36
|
+
const bg = colors?.background ?? 'transparent';
|
|
31
37
|
const titleColor = colors?.title ?? '#ffffff';
|
|
32
38
|
const accent = colors?.accent ?? '#FF9F0A';
|
|
33
|
-
const guidanceBg = colors?.guidanceBackground ?? 'rgba(
|
|
39
|
+
const guidanceBg = colors?.guidanceBackground ?? 'rgba(0,0,0,0.45)';
|
|
34
40
|
const guidanceColor = colors?.guidanceText ?? '#ffffff';
|
|
35
41
|
return (react_1.default.createElement(react_native_1.View, { style: [{ backgroundColor: bg }, style] },
|
|
36
|
-
react_1.default.createElement(react_native_1.View, { style: [styles.titleRow, { paddingTop: topInset +
|
|
42
|
+
react_1.default.createElement(react_native_1.View, { style: [styles.titleRow, { paddingTop: topInset + 4 }] },
|
|
37
43
|
onBack ? (react_1.default.createElement(react_native_1.Pressable, { onPress: onBack, hitSlop: 12, accessibilityRole: "button", accessibilityLabel: "Go back", style: styles.backButton },
|
|
38
|
-
react_1.default.createElement(react_native_1.Text, { style: [styles.backText, { color: accent }] }, backLabel))) : (
|
|
44
|
+
react_1.default.createElement(react_native_1.Text, { style: [styles.backText, styles.textShadow, { color: accent }] }, backLabel))) : (
|
|
39
45
|
// Empty spacer keeps the title centred even when back is hidden.
|
|
40
46
|
react_1.default.createElement(react_native_1.View, { style: styles.backButton })),
|
|
41
|
-
react_1.default.createElement(react_native_1.Text, { style: [styles.title, { color: titleColor }], numberOfLines: 1, accessibilityRole: "header" }, title),
|
|
47
|
+
react_1.default.createElement(react_native_1.Text, { style: [styles.title, styles.textShadow, { color: titleColor }], numberOfLines: 1, accessibilityRole: "header" }, title),
|
|
42
48
|
onSettingsPress ? (react_1.default.createElement(react_native_1.Pressable, { onPress: onSettingsPress, hitSlop: 12, accessibilityRole: "button", accessibilityLabel: "Open panorama settings", style: styles.backButton },
|
|
43
|
-
react_1.default.createElement(react_native_1.Text, { style: [styles.gearIcon, { color: accent }] }, "\u2699"))) : (react_1.default.createElement(react_native_1.View, { style: styles.backButton }))),
|
|
49
|
+
react_1.default.createElement(react_native_1.Text, { style: [styles.gearIcon, styles.textShadow, { color: accent }] }, "\u2699"))) : (react_1.default.createElement(react_native_1.View, { style: styles.backButton }))),
|
|
44
50
|
guidance ? (react_1.default.createElement(react_native_1.View, { style: [styles.guidance, { backgroundColor: guidanceBg }], accessibilityRole: "text" },
|
|
45
51
|
react_1.default.createElement(react_native_1.Text, { style: [styles.guidanceText, { color: guidanceColor }], numberOfLines: 2 }, guidance))) : null));
|
|
46
52
|
}
|
|
@@ -49,33 +55,50 @@ const styles = react_native_1.StyleSheet.create({
|
|
|
49
55
|
flexDirection: 'row',
|
|
50
56
|
alignItems: 'center',
|
|
51
57
|
justifyContent: 'space-between',
|
|
52
|
-
paddingHorizontal:
|
|
53
|
-
paddingBottom:
|
|
58
|
+
paddingHorizontal: 12,
|
|
59
|
+
paddingBottom: 4,
|
|
54
60
|
},
|
|
55
61
|
backButton: {
|
|
56
|
-
minWidth:
|
|
57
|
-
paddingVertical:
|
|
62
|
+
minWidth: 56,
|
|
63
|
+
paddingVertical: 2,
|
|
58
64
|
},
|
|
59
65
|
backText: {
|
|
60
|
-
fontSize:
|
|
66
|
+
fontSize: 14,
|
|
61
67
|
fontWeight: '500',
|
|
62
68
|
},
|
|
63
69
|
title: {
|
|
64
70
|
flex: 1,
|
|
65
71
|
textAlign: 'center',
|
|
66
|
-
fontSize:
|
|
72
|
+
fontSize: 14,
|
|
67
73
|
fontWeight: '600',
|
|
68
74
|
},
|
|
69
75
|
guidance: {
|
|
70
|
-
|
|
71
|
-
|
|
76
|
+
// v0.13.1 — guidance row is now a centred pill inset from the
|
|
77
|
+
// edges (matches the AR-toggle / lens-chip pill style) rather
|
|
78
|
+
// than a full-width band. The pill background gives it its
|
|
79
|
+
// own contrast over the preview without forcing a solid bar.
|
|
80
|
+
alignSelf: 'center',
|
|
81
|
+
marginTop: 4,
|
|
82
|
+
paddingHorizontal: 10,
|
|
83
|
+
paddingVertical: 5,
|
|
84
|
+
borderRadius: 12,
|
|
85
|
+
maxWidth: '90%',
|
|
72
86
|
},
|
|
73
87
|
guidanceText: {
|
|
74
|
-
fontSize:
|
|
88
|
+
fontSize: 12,
|
|
89
|
+
textAlign: 'center',
|
|
75
90
|
},
|
|
76
91
|
gearIcon: {
|
|
77
|
-
fontSize:
|
|
92
|
+
fontSize: 20,
|
|
78
93
|
textAlign: 'right',
|
|
79
94
|
},
|
|
95
|
+
// v0.13.1 — subtle text shadow so the (now-transparent) header
|
|
96
|
+
// text stays legible over bright preview content. Same trick
|
|
97
|
+
// iOS Camera uses for the timestamp / mode labels.
|
|
98
|
+
textShadow: {
|
|
99
|
+
textShadowColor: 'rgba(0,0,0,0.65)',
|
|
100
|
+
textShadowOffset: { width: 0, height: 1 },
|
|
101
|
+
textShadowRadius: 2,
|
|
102
|
+
},
|
|
80
103
|
});
|
|
81
104
|
//# sourceMappingURL=CaptureHeader.js.map
|
|
@@ -37,7 +37,19 @@ function CapturePreview({ visible, imageUri, imageWidth, imageHeight, actions, o
|
|
|
37
37
|
? imageWidth / imageHeight
|
|
38
38
|
: 16 / 9;
|
|
39
39
|
const hasActions = actions && actions.length > 0;
|
|
40
|
-
return (react_1.default.createElement(react_native_1.Modal, { visible: visible, animationType: "fade", transparent: true, statusBarTranslucent: true,
|
|
40
|
+
return (react_1.default.createElement(react_native_1.Modal, { visible: visible, animationType: "fade", transparent: true, statusBarTranslucent: true,
|
|
41
|
+
// v0.13.1 — RN's iOS <Modal> defaults to portrait-only, which
|
|
42
|
+
// pins the stitched-image preview to portrait even when the host
|
|
43
|
+
// app is in landscape (the preview appeared sideways/letterboxed
|
|
44
|
+
// under a non-locked host). Declaring all four keeps the modal
|
|
45
|
+
// aligned with the interface. Mirrors the v0.12 fix already on
|
|
46
|
+
// OrientationDriftModal + PanoramaSettingsModal.
|
|
47
|
+
supportedOrientations: [
|
|
48
|
+
'portrait',
|
|
49
|
+
'portrait-upside-down',
|
|
50
|
+
'landscape-left',
|
|
51
|
+
'landscape-right',
|
|
52
|
+
], onRequestClose: onClose },
|
|
41
53
|
react_1.default.createElement(react_native_1.View, { style: styles.backdrop },
|
|
42
54
|
react_1.default.createElement(react_native_1.View, { style: styles.topBar },
|
|
43
55
|
react_1.default.createElement(react_native_1.View, { style: styles.topBarSpacer }),
|
|
@@ -82,6 +82,30 @@ export interface CaptureThumbnailStripProps {
|
|
|
82
82
|
* stay under the strip's control to keep the count line consistent.
|
|
83
83
|
*/
|
|
84
84
|
style?: StyleProp<ViewStyle>;
|
|
85
|
+
/**
|
|
86
|
+
* v0.13.1 — when `true`, the strip stacks thumbnails VERTICALLY
|
|
87
|
+
* (column, scrolls up/down) instead of the default horizontal row.
|
|
88
|
+
* `<Camera>` sets this from the same `isSideEdge(homeIndicatorEdge)`
|
|
89
|
+
* signal that drives PanoramaBandOverlay's `vertical`, so under a
|
|
90
|
+
* non-locked host in landscape the idle capture strip stacks along
|
|
91
|
+
* the home-indicator edge like the live band does (rather than
|
|
92
|
+
* running horizontally across the middle of the rotated screen).
|
|
93
|
+
* Default `false` (legacy horizontal strip) — unchanged for
|
|
94
|
+
* portrait-locked hosts.
|
|
95
|
+
*/
|
|
96
|
+
vertical?: boolean;
|
|
97
|
+
/**
|
|
98
|
+
* v0.13.1 — counter-rotation applied to each thumbnail image so the
|
|
99
|
+
* captured scene reads upright when the device is held landscape
|
|
100
|
+
* under a PORTRAIT-LOCKED host (the JS framebuffer stays portrait, so
|
|
101
|
+
* the thumbnail would otherwise show 90° off). `<Camera>` passes the
|
|
102
|
+
* `useContentRotation()` result; `{}` (no-op) in upright cases.
|
|
103
|
+
* Applies only to the strip's own images — orientation of the strip's
|
|
104
|
+
* scroll axis is handled separately by `vertical`.
|
|
105
|
+
*/
|
|
106
|
+
contentRotation?: {
|
|
107
|
+
transform?: ViewStyle['transform'];
|
|
108
|
+
};
|
|
85
109
|
}
|
|
86
|
-
export declare function CaptureThumbnailStrip({ items, minPhotos, maxPhotos, backgroundColor, textColor, successColor, warningColor, disablePreview, onItemPress, style, }: CaptureThumbnailStripProps): React.JSX.Element;
|
|
110
|
+
export declare function CaptureThumbnailStrip({ items, minPhotos, maxPhotos, backgroundColor, textColor, successColor, warningColor, disablePreview, onItemPress, style, vertical, contentRotation, }: CaptureThumbnailStripProps): React.JSX.Element;
|
|
87
111
|
//# sourceMappingURL=CaptureThumbnailStrip.d.ts.map
|
|
@@ -91,7 +91,7 @@ function thumbWidth(item) {
|
|
|
91
91
|
const computed = Math.round(THUMB_HEIGHT * ratio);
|
|
92
92
|
return Math.max(THUMB_MIN_WIDTH, Math.min(THUMB_MAX_WIDTH, computed));
|
|
93
93
|
}
|
|
94
|
-
function CaptureThumbnailStrip({ items, minPhotos, maxPhotos, backgroundColor = 'rgba(0,0,0,0.85)', textColor = '#ffffff', successColor = '#34C759', warningColor = '#FF9F0A', disablePreview = false, onItemPress, style, }) {
|
|
94
|
+
function CaptureThumbnailStrip({ items, minPhotos, maxPhotos, backgroundColor = 'rgba(0,0,0,0.85)', textColor = '#ffffff', successColor = '#34C759', warningColor = '#FF9F0A', disablePreview = false, onItemPress, style, vertical = false, contentRotation, }) {
|
|
95
95
|
// Built-in preview state — only used when the host hasn't
|
|
96
96
|
// provided its own onItemPress handler. Letting the host pass a
|
|
97
97
|
// handler is how the AuditCaptureScreen unifies thumbnail
|
|
@@ -124,16 +124,19 @@ function CaptureThumbnailStrip({ items, minPhotos, maxPhotos, backgroundColor =
|
|
|
124
124
|
], accessibilityLabel: `Captured ${items.length} photos` }, text));
|
|
125
125
|
}, [items.length, minPhotos, maxPhotos, successColor, warningColor]);
|
|
126
126
|
return (react_1.default.createElement(react_native_1.View, { style: [styles.root, { backgroundColor }, style] },
|
|
127
|
-
react_1.default.createElement(react_native_1.FlatList, { data: items, horizontal:
|
|
127
|
+
react_1.default.createElement(react_native_1.FlatList, { data: items, horizontal: !vertical, showsHorizontalScrollIndicator: false, showsVerticalScrollIndicator: false, keyExtractor: (item) => item.id, contentContainerStyle: vertical ? styles.listContentVertical : styles.listContent, ListEmptyComponent: react_1.default.createElement(react_native_1.View, { style: [styles.placeholder, { borderColor: textColor }], accessibilityLabel: "No photos captured" },
|
|
128
128
|
react_1.default.createElement(react_native_1.Text, { style: [styles.placeholderText, { color: textColor }] }, "No photos")), renderItem: ({ item }) => (react_1.default.createElement(react_native_1.Pressable, { onPress: () => handleItemPress(item), disabled: disablePreview, accessibilityRole: "imagebutton", accessibilityLabel: "Open preview",
|
|
129
129
|
// Resolve the width per-item — done at render rather than
|
|
130
130
|
// inside renderItem's style prop so the function isn't
|
|
131
131
|
// re-created on every parent render.
|
|
132
132
|
style: [
|
|
133
133
|
styles.thumbWrapper,
|
|
134
|
+
// Spacing runs along the scroll axis: marginRight for the
|
|
135
|
+
// horizontal strip, marginBottom for the vertical column.
|
|
136
|
+
vertical ? styles.thumbWrapperVertical : styles.thumbWrapperHorizontal,
|
|
134
137
|
{ width: thumbWidth(item), height: THUMB_HEIGHT },
|
|
135
138
|
] },
|
|
136
|
-
react_1.default.createElement(react_native_1.Image, { source: { uri: item.uri }, style: styles.thumbImage, resizeMode: "cover" }))) }),
|
|
139
|
+
react_1.default.createElement(react_native_1.Image, { source: { uri: item.uri }, style: [styles.thumbImage, contentRotation], resizeMode: "cover" }))) }),
|
|
137
140
|
countLine,
|
|
138
141
|
react_1.default.createElement(CapturePreview_1.CapturePreview, { visible: previewItem !== null, imageUri: previewItem?.uri ?? '', imageWidth: previewItem?.width, imageHeight: previewItem?.height, onClose: closePreview })));
|
|
139
142
|
}
|
|
@@ -145,12 +148,22 @@ const styles = react_native_1.StyleSheet.create({
|
|
|
145
148
|
paddingHorizontal: 12,
|
|
146
149
|
alignItems: 'center',
|
|
147
150
|
},
|
|
151
|
+
listContentVertical: {
|
|
152
|
+
paddingVertical: 12,
|
|
153
|
+
alignItems: 'center',
|
|
154
|
+
},
|
|
148
155
|
thumbWrapper: {
|
|
149
|
-
marginRight: 8,
|
|
150
156
|
borderRadius: 4,
|
|
151
157
|
overflow: 'hidden',
|
|
152
158
|
backgroundColor: '#222',
|
|
153
159
|
},
|
|
160
|
+
// Spacing applied along the scroll axis (see render site).
|
|
161
|
+
thumbWrapperHorizontal: {
|
|
162
|
+
marginRight: 8,
|
|
163
|
+
},
|
|
164
|
+
thumbWrapperVertical: {
|
|
165
|
+
marginBottom: 8,
|
|
166
|
+
},
|
|
154
167
|
thumbImage: {
|
|
155
168
|
width: '100%',
|
|
156
169
|
height: '100%',
|
|
@@ -115,5 +115,81 @@ export interface PanoramaBandOverlayProps {
|
|
|
115
115
|
*/
|
|
116
116
|
captureOrientation?: BandCaptureOrientation;
|
|
117
117
|
}
|
|
118
|
+
/**
|
|
119
|
+
* Resolve band layout from capture orientation. 2026-05-18 (Issue #3)
|
|
120
|
+
* — uses the 4-way `BandCaptureOrientation` instead of the 2-way
|
|
121
|
+
* `state.isLandscape` so we can pick the right flex direction +
|
|
122
|
+
* arrow glyph in EACH landscape rotation.
|
|
123
|
+
*
|
|
124
|
+
* The two landscape rotations require different JS-coordinate setups
|
|
125
|
+
* because the phone tilts the JS coordinate system relative to the
|
|
126
|
+
* user differently:
|
|
127
|
+
*
|
|
128
|
+
* LANDSCAPE-LEFT (Apple: home indicator on user's RIGHT; phone
|
|
129
|
+
* rotated 90° CCW from portrait).
|
|
130
|
+
* JS-left = user-top
|
|
131
|
+
* JS-right = user-bottom
|
|
132
|
+
* Band at JS-bottom edge appears on user's RIGHT edge.
|
|
133
|
+
* For "oldest at user-top, newest at user-bottom":
|
|
134
|
+
* flexDirection = 'row' (array[0] at JS-left = user-top).
|
|
135
|
+
* For arrow appearing as user-DOWN-arrow:
|
|
136
|
+
* glyph `←` (rotated 90° CCW = points user-down).
|
|
137
|
+
*
|
|
138
|
+
* LANDSCAPE-RIGHT (Apple: home indicator on user's LEFT; phone
|
|
139
|
+
* rotated 90° CW from portrait).
|
|
140
|
+
* JS-left = user-bottom
|
|
141
|
+
* JS-right = user-top
|
|
142
|
+
* Band at JS-TOP edge appears on user's RIGHT edge (so we move
|
|
143
|
+
* the band to JS-top here, not JS-bottom).
|
|
144
|
+
* For "oldest at user-top, newest at user-bottom":
|
|
145
|
+
* flexDirection = 'row-reverse' (array[0] at JS-right = user-top).
|
|
146
|
+
* For arrow appearing as user-DOWN-arrow:
|
|
147
|
+
* glyph `→` (rotated 90° CW = points user-down).
|
|
148
|
+
*
|
|
149
|
+
* PORTRAIT (and portrait-upside-down — collapsed because the band's
|
|
150
|
+
* bottom-anchored position remains sensible either way):
|
|
151
|
+
* Band at JS-bottom = user-bottom. Row left-to-right. Arrow `→`
|
|
152
|
+
* reads as user-right-arrow (pointing along the horizontal pan
|
|
153
|
+
* direction).
|
|
154
|
+
*/
|
|
155
|
+
/**
|
|
156
|
+
* v0.13.1 — pure rotation-decision helpers, extracted for unit testing
|
|
157
|
+
* (the lib's jest config is pure-TS, no component mounting; see
|
|
158
|
+
* jest.config.js). These encode the orientation contract the band
|
|
159
|
+
* relies on, so a regression in the angles/branches is caught in CI
|
|
160
|
+
* rather than only on-device.
|
|
161
|
+
*
|
|
162
|
+
* `bandThumbRotation` — the CSS rotate transform that aligns a thumb's
|
|
163
|
+
* pixels with the band box. Returns the transform array RN expects, or
|
|
164
|
+
* `undefined` for "no rotation". Two regimes:
|
|
165
|
+
* - vertical=false (portrait-locked UI): the box is device-aligned, so
|
|
166
|
+
* a landscape device needs a 90° counter-rotation (CW for
|
|
167
|
+
* landscape-left, CCW for landscape-right).
|
|
168
|
+
* - vertical=true (non-locked, OS-rotated framebuffer): the screen
|
|
169
|
+
* rotation already did half the work, so the compensation is the
|
|
170
|
+
* OPPOSITE sign.
|
|
171
|
+
* Exported as `_bandThumbRotationForTests`.
|
|
172
|
+
*/
|
|
173
|
+
declare function bandThumbRotation(orientation: BandCaptureOrientation, vertical: boolean): Array<{
|
|
174
|
+
rotate: string;
|
|
175
|
+
}> | undefined;
|
|
176
|
+
/**
|
|
177
|
+
* v0.13.1 — the rotation actually applied to the per-keyframe (multi-
|
|
178
|
+
* thumb) TILES. This is the EXIF double-rotation fix: the saved
|
|
179
|
+
* `keyframe-N.jpg` is sensor-native landscape + EXIF Orientation 6, which
|
|
180
|
+
* RN's <Image> already auto-rotates upright. So in the portrait-locked
|
|
181
|
+
* (vertical=false) path NO further transform is applied — adding one
|
|
182
|
+
* double-rotates (the original v0.12 bug). Only the non-locked
|
|
183
|
+
* (vertical=true) path needs the compensation. Returns `undefined` for
|
|
184
|
+
* "no transform". Exported as `_tileRotationForTests`.
|
|
185
|
+
*/
|
|
186
|
+
declare function tileRotation(orientation: BandCaptureOrientation, vertical: boolean): Array<{
|
|
187
|
+
rotate: string;
|
|
188
|
+
}> | undefined;
|
|
189
|
+
/** @internal test-only export — see `bandThumbRotation`. */
|
|
190
|
+
export declare const _bandThumbRotationForTests: typeof bandThumbRotation;
|
|
191
|
+
/** @internal test-only export — see `tileRotation`. */
|
|
192
|
+
export declare const _tileRotationForTests: typeof tileRotation;
|
|
118
193
|
export declare function PanoramaBandOverlay({ state, frameUris, captureOrientation, vertical, }: PanoramaBandOverlayProps): React.JSX.Element | null;
|
|
194
|
+
export {};
|
|
119
195
|
//# sourceMappingURL=PanoramaBandOverlay.d.ts.map
|
|
@@ -92,6 +92,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
92
92
|
};
|
|
93
93
|
})();
|
|
94
94
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
95
|
+
exports._tileRotationForTests = exports._bandThumbRotationForTests = void 0;
|
|
95
96
|
exports.PanoramaBandOverlay = PanoramaBandOverlay;
|
|
96
97
|
const react_1 = __importStar(require("react"));
|
|
97
98
|
const react_native_1 = require("react-native");
|
|
@@ -141,6 +142,55 @@ const MULTI_THUMB_HARD_CAP = 32; // safety net; host typically caps at 24
|
|
|
141
142
|
* reads as user-right-arrow (pointing along the horizontal pan
|
|
142
143
|
* direction).
|
|
143
144
|
*/
|
|
145
|
+
/**
|
|
146
|
+
* v0.13.1 — pure rotation-decision helpers, extracted for unit testing
|
|
147
|
+
* (the lib's jest config is pure-TS, no component mounting; see
|
|
148
|
+
* jest.config.js). These encode the orientation contract the band
|
|
149
|
+
* relies on, so a regression in the angles/branches is caught in CI
|
|
150
|
+
* rather than only on-device.
|
|
151
|
+
*
|
|
152
|
+
* `bandThumbRotation` — the CSS rotate transform that aligns a thumb's
|
|
153
|
+
* pixels with the band box. Returns the transform array RN expects, or
|
|
154
|
+
* `undefined` for "no rotation". Two regimes:
|
|
155
|
+
* - vertical=false (portrait-locked UI): the box is device-aligned, so
|
|
156
|
+
* a landscape device needs a 90° counter-rotation (CW for
|
|
157
|
+
* landscape-left, CCW for landscape-right).
|
|
158
|
+
* - vertical=true (non-locked, OS-rotated framebuffer): the screen
|
|
159
|
+
* rotation already did half the work, so the compensation is the
|
|
160
|
+
* OPPOSITE sign.
|
|
161
|
+
* Exported as `_bandThumbRotationForTests`.
|
|
162
|
+
*/
|
|
163
|
+
function bandThumbRotation(orientation, vertical) {
|
|
164
|
+
if (vertical) {
|
|
165
|
+
if (orientation === 'landscape-left')
|
|
166
|
+
return [{ rotate: '-90deg' }];
|
|
167
|
+
if (orientation === 'landscape-right')
|
|
168
|
+
return [{ rotate: '90deg' }];
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
if (orientation === 'landscape-left')
|
|
172
|
+
return [{ rotate: '90deg' }];
|
|
173
|
+
if (orientation === 'landscape-right')
|
|
174
|
+
return [{ rotate: '-90deg' }];
|
|
175
|
+
return undefined;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* v0.13.1 — the rotation actually applied to the per-keyframe (multi-
|
|
179
|
+
* thumb) TILES. This is the EXIF double-rotation fix: the saved
|
|
180
|
+
* `keyframe-N.jpg` is sensor-native landscape + EXIF Orientation 6, which
|
|
181
|
+
* RN's <Image> already auto-rotates upright. So in the portrait-locked
|
|
182
|
+
* (vertical=false) path NO further transform is applied — adding one
|
|
183
|
+
* double-rotates (the original v0.12 bug). Only the non-locked
|
|
184
|
+
* (vertical=true) path needs the compensation. Returns `undefined` for
|
|
185
|
+
* "no transform". Exported as `_tileRotationForTests`.
|
|
186
|
+
*/
|
|
187
|
+
function tileRotation(orientation, vertical) {
|
|
188
|
+
return vertical ? bandThumbRotation(orientation, vertical) : undefined;
|
|
189
|
+
}
|
|
190
|
+
/** @internal test-only export — see `bandThumbRotation`. */
|
|
191
|
+
exports._bandThumbRotationForTests = bandThumbRotation;
|
|
192
|
+
/** @internal test-only export — see `tileRotation`. */
|
|
193
|
+
exports._tileRotationForTests = tileRotation;
|
|
144
194
|
function layoutFor(orientation, vertical) {
|
|
145
195
|
const commonInner = {
|
|
146
196
|
alignItems: 'center',
|
|
@@ -326,42 +376,49 @@ function PanoramaBandOverlay({ state, frameUris, captureOrientation, vertical =
|
|
|
326
376
|
// transform, so they appeared sideways in portrait-locked
|
|
327
377
|
// landscape captures (the case the example app's batch-keyframe
|
|
328
378
|
// engine hits).
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
// device-orientation rotation via finalize(). So:
|
|
334
|
-
//
|
|
335
|
-
// jsPortrait box + landscape device: box is device-aligned;
|
|
336
|
-
// image's "up" is at file-right (sensor convention). Rotate
|
|
337
|
-
// 90° CW (landscape-left) / 90° CCW (landscape-right) to
|
|
338
|
-
// align image up with box up.
|
|
339
|
-
// jsLandscape box + landscape device: box is user-aligned via
|
|
340
|
-
// OS screen rotation; image's "up" still at file-right. To
|
|
341
|
-
// align image up with box up, rotate the OPPOSITE direction
|
|
342
|
-
// from the jsPortrait case — the screen-rotation already
|
|
343
|
-
// handles half the work; we just need to compensate for the
|
|
344
|
-
// remaining mismatch.
|
|
345
|
-
if (vertical) {
|
|
346
|
-
if (resolvedOrientation === 'landscape-left')
|
|
347
|
-
return [{ rotate: '-90deg' }];
|
|
348
|
-
if (resolvedOrientation === 'landscape-right')
|
|
349
|
-
return [{ rotate: '90deg' }];
|
|
350
|
-
return undefined;
|
|
351
|
-
}
|
|
352
|
-
if (resolvedOrientation === 'landscape-left')
|
|
353
|
-
return [{ rotate: '90deg' }];
|
|
354
|
-
if (resolvedOrientation === 'landscape-right')
|
|
355
|
-
return [{ rotate: '-90deg' }];
|
|
356
|
-
return undefined;
|
|
357
|
-
}, [resolvedOrientation, vertical]);
|
|
379
|
+
// Rotation for the single cumulative thumb (panorama-*.jpg, a JFIF
|
|
380
|
+
// with NO EXIF tag → RN does not auto-rotate it, so the transform is
|
|
381
|
+
// always needed). See `bandThumbRotation` for the angle contract.
|
|
382
|
+
const thumbRotationTransform = (0, react_1.useMemo)(() => bandThumbRotation(resolvedOrientation, vertical), [resolvedOrientation, vertical]);
|
|
358
383
|
const singleImageStyle = (0, react_1.useMemo)(() => thumbRotationTransform
|
|
359
384
|
? [react_native_1.StyleSheet.absoluteFill, { transform: thumbRotationTransform }]
|
|
360
385
|
: react_native_1.StyleSheet.absoluteFill, [thumbRotationTransform]);
|
|
361
|
-
//
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
386
|
+
// v0.13.1 — per-keyframe tile rotation is conditional on `vertical`.
|
|
387
|
+
//
|
|
388
|
+
// The keyframe JPEGs (`keyframe-N.jpg`) are saved as sensor-native
|
|
389
|
+
// landscape PIXELS *plus* an EXIF Orientation tag (= 6, "rotate 90°
|
|
390
|
+
// CW for display") — verified on-device: Android SM-A356U1 640×480
|
|
391
|
+
// + EXIF6, iOS iPhone16Pro 1920×1080 + EXIF6. RN's <Image> (Fresco
|
|
392
|
+
// on Android, ImageIO on iOS) HONORS EXIF and auto-rotates each tile
|
|
393
|
+
// to gravity-upright on its own. Whether a *further* JS transform is
|
|
394
|
+
// needed depends on the band box's coordinate frame:
|
|
395
|
+
//
|
|
396
|
+
// vertical=false (portrait-locked UI): box is in portrait JS coords,
|
|
397
|
+
// which align with the EXIF-upright tile → NO transform. Applying
|
|
398
|
+
// one here double-rotates (the original v0.12 bug — tiles appeared
|
|
399
|
+
// 90° off in portrait-locked landscape captures). Verified fixed
|
|
400
|
+
// on Android portrait-lock.
|
|
401
|
+
// vertical=true (non-locked host, device-landscape): box is in
|
|
402
|
+
// landscape JS coords, rotated 90° from the EXIF-upright tile →
|
|
403
|
+
// the counter-rotation is STILL required (verified on iOS: with no
|
|
404
|
+
// transform the tiles sit 90° off).
|
|
405
|
+
//
|
|
406
|
+
// So reuse `thumbRotationTransform` (which already encodes the correct
|
|
407
|
+
// per-orientation angle) ONLY in the vertical=true branch.
|
|
408
|
+
//
|
|
409
|
+
// The single cumulative thumb above always needs the transform: its
|
|
410
|
+
// source (`panorama-*.jpg`) is a JFIF with NO EXIF tag (verified:
|
|
411
|
+
// header ff d8 ff e0), so RN never auto-rotates it.
|
|
412
|
+
//
|
|
413
|
+
// Stitcher is unaffected — it reads `keyframe-N.jpg` with EXIF IGNORED
|
|
414
|
+
// (IMREAD_IGNORE_ORIENTATION) so it still gets the sensor-native
|
|
415
|
+
// pixels its pose intrinsics expect. Display-only.
|
|
416
|
+
const multiThumbStyle = (0, react_1.useMemo)(() => {
|
|
417
|
+
const tileTransform = tileRotation(resolvedOrientation, vertical);
|
|
418
|
+
return tileTransform
|
|
419
|
+
? [styles.multiThumb, { transform: tileTransform }]
|
|
420
|
+
: styles.multiThumb;
|
|
421
|
+
}, [resolvedOrientation, vertical]);
|
|
365
422
|
return (react_1.default.createElement(react_native_1.View, { pointerEvents: "none", style: [styles.bandBase, layout.band] }, hasMultiThumb ? (
|
|
366
423
|
// Multi-thumb path: one image per accepted keyframe, scrolling
|
|
367
424
|
// horizontally (in JS-coords) within the band. Content
|
|
@@ -44,7 +44,17 @@ function PanoramaConfirmModal({ visible, panoramaUri, width, height, onSave, onR
|
|
|
44
44
|
// correctly inside a flexible container without us having to
|
|
45
45
|
// measure the modal's available area on every layout change.
|
|
46
46
|
const aspectRatio = width > 0 && height > 0 ? width / height : 16 / 9;
|
|
47
|
-
return (react_1.default.createElement(react_native_1.Modal, { visible: visible, animationType: "fade", transparent: true, statusBarTranslucent: true,
|
|
47
|
+
return (react_1.default.createElement(react_native_1.Modal, { visible: visible, animationType: "fade", transparent: true, statusBarTranslucent: true,
|
|
48
|
+
// v0.13.1 — RN's iOS <Modal> defaults to portrait-only. Declare
|
|
49
|
+
// all four so the confirm modal stays aligned with the interface
|
|
50
|
+
// under a non-locked host. Mirrors OrientationDriftModal +
|
|
51
|
+
// PanoramaSettingsModal (v0.12) and CapturePreview (v0.13.1).
|
|
52
|
+
supportedOrientations: [
|
|
53
|
+
'portrait',
|
|
54
|
+
'portrait-upside-down',
|
|
55
|
+
'landscape-left',
|
|
56
|
+
'landscape-right',
|
|
57
|
+
], onRequestClose: onDiscard },
|
|
48
58
|
react_1.default.createElement(react_native_1.View, { style: styles.backdrop },
|
|
49
59
|
react_1.default.createElement(react_native_1.Text, { style: styles.title, accessibilityRole: "header" }, title),
|
|
50
60
|
react_1.default.createElement(react_native_1.View, { style: styles.imageWrapper },
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* selectCaptureDevice — capability-aware back-camera selection.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the single-physical-device request that caused two
|
|
5
|
+
* user-visible bugs (see docs/plans/2026-06-01-v0.13.2-multilens-
|
|
6
|
+
* device-selection.md):
|
|
7
|
+
*
|
|
8
|
+
* 1. 0.5× silently showed the wide-angle FOV on phones where the
|
|
9
|
+
* ultra-wide is only exposed inside a multi-cam logical device —
|
|
10
|
+
* vision-camera's single-lens filter mis-scored and fell back to
|
|
11
|
+
* a plain wide-angle device.
|
|
12
|
+
* 2. flash threw `flash-not-available` on 0.5× because the standalone
|
|
13
|
+
* ultra-wide device has no torch unit.
|
|
14
|
+
*
|
|
15
|
+
* Both stem from mounting ONE standalone physical device per lens. The
|
|
16
|
+
* fix: prefer a MULTI-CAM device that carries the ultra-wide (so a
|
|
17
|
+
* single mounted device spans both FOVs via zoom AND carries the torch
|
|
18
|
+
* through its wide-angle member). Fall back to standalone devices for
|
|
19
|
+
* phones — common on Android — where the ultra-wide has no multi-cam
|
|
20
|
+
* grouping, so we don't regress those.
|
|
21
|
+
*
|
|
22
|
+
* Pure + synchronous: takes a plain device list (the structural subset
|
|
23
|
+
* of vision-camera's `CameraDevice` we need) and returns the choice.
|
|
24
|
+
* No React, no vision-camera hooks — unit-tested directly.
|
|
25
|
+
*/
|
|
26
|
+
export type LensType = 'ultra-wide-angle-camera' | 'wide-angle-camera' | 'telephoto-camera';
|
|
27
|
+
/**
|
|
28
|
+
* The structural subset of vision-camera's `CameraDevice` this selector
|
|
29
|
+
* reads. Declared locally (not imported) so tests can build synthetic
|
|
30
|
+
* devices without the full vision-camera type, and so the SDK doesn't
|
|
31
|
+
* couple its selection logic to vision-camera's evolving shape.
|
|
32
|
+
*/
|
|
33
|
+
export interface DeviceLike {
|
|
34
|
+
id: string;
|
|
35
|
+
position: 'front' | 'back' | 'external';
|
|
36
|
+
physicalDevices: LensType[];
|
|
37
|
+
isMultiCam: boolean;
|
|
38
|
+
hasTorch: boolean;
|
|
39
|
+
minZoom: number;
|
|
40
|
+
neutralZoom: number;
|
|
41
|
+
maxZoom: number;
|
|
42
|
+
}
|
|
43
|
+
export type CaptureDeviceMode =
|
|
44
|
+
/** One multi-cam device spans wide + ultra-wide; switch lenses via zoom. */
|
|
45
|
+
'multicam'
|
|
46
|
+
/** Separate standalone wide + ultra-wide devices; switch by remounting. */
|
|
47
|
+
| 'standalone-uw'
|
|
48
|
+
/** No ultra-wide anywhere; wide-angle only (no 0.5× chip). */
|
|
49
|
+
| 'wide-only';
|
|
50
|
+
export interface CaptureDeviceSelection<D extends DeviceLike = DeviceLike> {
|
|
51
|
+
/** The device to mount for the `1×` lens (and for `multicam`, all lenses). */
|
|
52
|
+
device: D | null;
|
|
53
|
+
/**
|
|
54
|
+
* The device to mount when the user picks `0.5×` in `standalone-uw`
|
|
55
|
+
* mode (a separate physical ultra-wide). Null in `multicam` (same
|
|
56
|
+
* device, zoom instead) and `wide-only` (no ultra-wide).
|
|
57
|
+
*/
|
|
58
|
+
ultraWideDevice: D | null;
|
|
59
|
+
mode: CaptureDeviceMode;
|
|
60
|
+
/** Whether a 0.5× chooser should be offered at all. */
|
|
61
|
+
has0_5x: boolean;
|
|
62
|
+
/** Whether the `1×`/primary mounted device can flash (drives flash UI). */
|
|
63
|
+
hasTorch: boolean;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Choose the back-camera device(s) for capture.
|
|
67
|
+
*
|
|
68
|
+
* Priority:
|
|
69
|
+
* 1. multicam — a multi-cam device containing BOTH wide + ultra-wide
|
|
70
|
+
* (best: one device, zoom-switch, torch via the wide member).
|
|
71
|
+
* 2. standalone-uw — a standalone wide AND a standalone ultra-wide
|
|
72
|
+
* exist as separate devices (device-swap on lens change; flash
|
|
73
|
+
* hidden on the torchless ultra-wide).
|
|
74
|
+
* 3. wide-only — no ultra-wide reachable; wide-angle only.
|
|
75
|
+
*
|
|
76
|
+
* @param devices All enumerated camera devices (any position).
|
|
77
|
+
*/
|
|
78
|
+
export declare function selectCaptureDevice<D extends DeviceLike>(devices: readonly D[]): CaptureDeviceSelection<D>;
|
|
79
|
+
/**
|
|
80
|
+
* Map a UI lens label to a vision-camera `zoom` value for the
|
|
81
|
+
* `multicam` mode (where lens switching is zoom, not device swap).
|
|
82
|
+
*
|
|
83
|
+
* - `1×` → the device's `neutralZoom` (wide-angle baseline; vision-
|
|
84
|
+
* camera docs: "where the camera is in wide-angle mode and hasn't
|
|
85
|
+
* switched to ultra-wide or telephoto yet").
|
|
86
|
+
* - `0.5×` → `minZoom` (the ultra-wide end of the zoom range).
|
|
87
|
+
*
|
|
88
|
+
* Returns `neutralZoom` for any non-0.5× label as a safe default.
|
|
89
|
+
* Only meaningful in `multicam` mode; the standalone path swaps devices
|
|
90
|
+
* and ignores this.
|
|
91
|
+
*/
|
|
92
|
+
export declare function zoomForLens(device: Pick<DeviceLike, 'minZoom' | 'neutralZoom'>, lens: '1x' | '0.5x'): number;
|
|
93
|
+
//# sourceMappingURL=selectCaptureDevice.d.ts.map
|