react-native-image-stitcher 0.13.0 → 0.14.1
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 +115 -0
- package/README.md +238 -62
- 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
|
@@ -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
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
/**
|
|
4
|
+
* selectCaptureDevice — capability-aware back-camera selection.
|
|
5
|
+
*
|
|
6
|
+
* Replaces the single-physical-device request that caused two
|
|
7
|
+
* user-visible bugs (see docs/plans/2026-06-01-v0.13.2-multilens-
|
|
8
|
+
* device-selection.md):
|
|
9
|
+
*
|
|
10
|
+
* 1. 0.5× silently showed the wide-angle FOV on phones where the
|
|
11
|
+
* ultra-wide is only exposed inside a multi-cam logical device —
|
|
12
|
+
* vision-camera's single-lens filter mis-scored and fell back to
|
|
13
|
+
* a plain wide-angle device.
|
|
14
|
+
* 2. flash threw `flash-not-available` on 0.5× because the standalone
|
|
15
|
+
* ultra-wide device has no torch unit.
|
|
16
|
+
*
|
|
17
|
+
* Both stem from mounting ONE standalone physical device per lens. The
|
|
18
|
+
* fix: prefer a MULTI-CAM device that carries the ultra-wide (so a
|
|
19
|
+
* single mounted device spans both FOVs via zoom AND carries the torch
|
|
20
|
+
* through its wide-angle member). Fall back to standalone devices for
|
|
21
|
+
* phones — common on Android — where the ultra-wide has no multi-cam
|
|
22
|
+
* grouping, so we don't regress those.
|
|
23
|
+
*
|
|
24
|
+
* Pure + synchronous: takes a plain device list (the structural subset
|
|
25
|
+
* of vision-camera's `CameraDevice` we need) and returns the choice.
|
|
26
|
+
* No React, no vision-camera hooks — unit-tested directly.
|
|
27
|
+
*/
|
|
28
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
29
|
+
exports.selectCaptureDevice = selectCaptureDevice;
|
|
30
|
+
exports.zoomForLens = zoomForLens;
|
|
31
|
+
const hasLens = (d, lens) => d.physicalDevices.includes(lens);
|
|
32
|
+
/**
|
|
33
|
+
* Choose the back-camera device(s) for capture.
|
|
34
|
+
*
|
|
35
|
+
* Priority:
|
|
36
|
+
* 1. multicam — a multi-cam device containing BOTH wide + ultra-wide
|
|
37
|
+
* (best: one device, zoom-switch, torch via the wide member).
|
|
38
|
+
* 2. standalone-uw — a standalone wide AND a standalone ultra-wide
|
|
39
|
+
* exist as separate devices (device-swap on lens change; flash
|
|
40
|
+
* hidden on the torchless ultra-wide).
|
|
41
|
+
* 3. wide-only — no ultra-wide reachable; wide-angle only.
|
|
42
|
+
*
|
|
43
|
+
* @param devices All enumerated camera devices (any position).
|
|
44
|
+
*/
|
|
45
|
+
function selectCaptureDevice(devices) {
|
|
46
|
+
const back = devices.filter((d) => d.position === 'back');
|
|
47
|
+
if (back.length === 0) {
|
|
48
|
+
return {
|
|
49
|
+
device: null,
|
|
50
|
+
ultraWideDevice: null,
|
|
51
|
+
mode: 'wide-only',
|
|
52
|
+
has0_5x: false,
|
|
53
|
+
hasTorch: false,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
// ── 1. Prefer a multi-cam device that carries BOTH wide + ultra-wide.
|
|
57
|
+
// Among candidates, prefer the one that ALSO has a torch (so flash
|
|
58
|
+
// works on every lens), then the one spanning the widest zoom range
|
|
59
|
+
// (more lenses → more reach), as a stable tiebreak.
|
|
60
|
+
const multicamCandidates = back.filter((d) => d.isMultiCam &&
|
|
61
|
+
hasLens(d, 'wide-angle-camera') &&
|
|
62
|
+
hasLens(d, 'ultra-wide-angle-camera'));
|
|
63
|
+
if (multicamCandidates.length > 0) {
|
|
64
|
+
const device = multicamCandidates.reduce((best, d) => {
|
|
65
|
+
// torch-bearing wins; then wider zoom span; then more lenses.
|
|
66
|
+
if (d.hasTorch !== best.hasTorch)
|
|
67
|
+
return d.hasTorch ? d : best;
|
|
68
|
+
const span = d.maxZoom - d.minZoom;
|
|
69
|
+
const bestSpan = best.maxZoom - best.minZoom;
|
|
70
|
+
if (span !== bestSpan)
|
|
71
|
+
return span > bestSpan ? d : best;
|
|
72
|
+
return d.physicalDevices.length > best.physicalDevices.length ? d : best;
|
|
73
|
+
});
|
|
74
|
+
return {
|
|
75
|
+
device,
|
|
76
|
+
ultraWideDevice: null,
|
|
77
|
+
mode: 'multicam',
|
|
78
|
+
has0_5x: true,
|
|
79
|
+
hasTorch: device.hasTorch,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
// ── 2. Standalone ultra-wide + standalone wide as separate devices.
|
|
83
|
+
// CRITICAL: this fallback is what keeps phones (esp. Android) where
|
|
84
|
+
// the ultra-wide has NO multi-cam grouping working — without it,
|
|
85
|
+
// restricting to multicam would REINTRODUCE the "0.5× shows wide" bug
|
|
86
|
+
// for that device population.
|
|
87
|
+
//
|
|
88
|
+
// Prefer a torch-bearing wide-angle device as the `1×`/primary mount.
|
|
89
|
+
const wideDevices = back.filter((d) => hasLens(d, 'wide-angle-camera'));
|
|
90
|
+
const ultraWide = back.find((d) => !d.isMultiCam && hasLens(d, 'ultra-wide-angle-camera')) ??
|
|
91
|
+
back.find((d) => hasLens(d, 'ultra-wide-angle-camera')) ??
|
|
92
|
+
null;
|
|
93
|
+
if (wideDevices.length > 0 && ultraWide != null) {
|
|
94
|
+
// Prefer the simplest wide device (fewest extra lenses) with a torch
|
|
95
|
+
// as the 1× mount, so 1× flash works. Falls back to any wide device.
|
|
96
|
+
const primary = wideDevices.find((d) => d.hasTorch) ?? wideDevices[0];
|
|
97
|
+
return {
|
|
98
|
+
device: primary,
|
|
99
|
+
ultraWideDevice: ultraWide,
|
|
100
|
+
mode: 'standalone-uw',
|
|
101
|
+
has0_5x: true,
|
|
102
|
+
hasTorch: primary.hasTorch,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
// ── 3. Wide-angle only (no ultra-wide reachable on this device).
|
|
106
|
+
const wideOnly = wideDevices.find((d) => d.hasTorch) ?? wideDevices[0] ?? back[0];
|
|
107
|
+
return {
|
|
108
|
+
device: wideOnly,
|
|
109
|
+
ultraWideDevice: null,
|
|
110
|
+
mode: 'wide-only',
|
|
111
|
+
has0_5x: false,
|
|
112
|
+
hasTorch: wideOnly.hasTorch,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Map a UI lens label to a vision-camera `zoom` value for the
|
|
117
|
+
* `multicam` mode (where lens switching is zoom, not device swap).
|
|
118
|
+
*
|
|
119
|
+
* - `1×` → the device's `neutralZoom` (wide-angle baseline; vision-
|
|
120
|
+
* camera docs: "where the camera is in wide-angle mode and hasn't
|
|
121
|
+
* switched to ultra-wide or telephoto yet").
|
|
122
|
+
* - `0.5×` → `minZoom` (the ultra-wide end of the zoom range).
|
|
123
|
+
*
|
|
124
|
+
* Returns `neutralZoom` for any non-0.5× label as a safe default.
|
|
125
|
+
* Only meaningful in `multicam` mode; the standalone path swaps devices
|
|
126
|
+
* and ignores this.
|
|
127
|
+
*/
|
|
128
|
+
function zoomForLens(device, lens) {
|
|
129
|
+
return lens === '0.5x' ? device.minZoom : device.neutralZoom;
|
|
130
|
+
}
|
|
131
|
+
//# sourceMappingURL=selectCaptureDevice.js.map
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
* still use the SDK's quality + stitching modules.
|
|
25
25
|
*/
|
|
26
26
|
import { Camera, useCameraDevice, type PhysicalCameraDeviceType, type TakePhotoOptions } from 'react-native-vision-camera';
|
|
27
|
+
import { type CaptureDeviceMode } from './selectCaptureDevice';
|
|
27
28
|
import type { CaptureResult, QualityThresholds } from '../types';
|
|
28
29
|
/**
|
|
29
30
|
* Hook input. Everything optional; sensible defaults are applied
|
|
@@ -66,8 +67,22 @@ export interface UseCaptureOptions {
|
|
|
66
67
|
* behaves as if `preferredPhysicalDevice` was undefined). The
|
|
67
68
|
* returned `availablePhysicalDevices` exposes what the device
|
|
68
69
|
* actually offers so the host can render an appropriate switcher.
|
|
70
|
+
*
|
|
71
|
+
* v0.13.2 — superseded by `lens` for `<Camera>`'s own use (see
|
|
72
|
+
* `selectCaptureDevice`). Still honoured for direct Layer-2 hosts.
|
|
69
73
|
*/
|
|
70
74
|
preferredPhysicalDevice?: PhysicalCameraDeviceType;
|
|
75
|
+
/**
|
|
76
|
+
* v0.13.2 — the active UI lens (`1×` / `0.5×`). When supplied, the
|
|
77
|
+
* hook uses capability-aware selection (`selectCaptureDevice`): it
|
|
78
|
+
* prefers a multi-cam device spanning both FOVs (lens switched via
|
|
79
|
+
* `zoom`, torch available on every lens), and falls back to a
|
|
80
|
+
* standalone ultra-wide device-swap only where no such multi-cam
|
|
81
|
+
* device exists. Fixes the "0.5× shows wide-angle on some phones"
|
|
82
|
+
* and "flash unavailable on 0.5×" bugs. When omitted, the legacy
|
|
83
|
+
* `preferredPhysicalDevice` path is used (backwards-compatible).
|
|
84
|
+
*/
|
|
85
|
+
lens?: '1x' | '0.5x';
|
|
71
86
|
}
|
|
72
87
|
/**
|
|
73
88
|
* Per-call options for `takePhoto`. Separate from `UseCaptureOptions`
|
|
@@ -136,6 +151,31 @@ export interface UseCaptureReturn {
|
|
|
136
151
|
* load). Always populated by the time the camera is mountable.
|
|
137
152
|
*/
|
|
138
153
|
availablePhysicalDevices: PhysicalCameraDeviceType[];
|
|
154
|
+
/**
|
|
155
|
+
* v0.13.2 — how lenses are switched for the mounted device:
|
|
156
|
+
* 'multicam' — one device spans both FOVs; switch via `deviceZoom`.
|
|
157
|
+
* 'standalone-uw' — separate ultra-wide device; switch by remounting.
|
|
158
|
+
* 'wide-only' — no ultra-wide; no 0.5× chooser.
|
|
159
|
+
*/
|
|
160
|
+
captureMode: CaptureDeviceMode;
|
|
161
|
+
/**
|
|
162
|
+
* v0.13.2 — whether the device can offer a 0.5× ultra-wide lens AT ALL
|
|
163
|
+
* (real capability, replacing the old hardcoded assumption). Drives
|
|
164
|
+
* whether `<Camera>` renders the lens chooser.
|
|
165
|
+
*/
|
|
166
|
+
has0_5x: boolean;
|
|
167
|
+
/**
|
|
168
|
+
* v0.13.2 — whether the currently-MOUNTED device has a torch. Drives
|
|
169
|
+
* the flash control's availability (the standalone ultra-wide has none).
|
|
170
|
+
*/
|
|
171
|
+
deviceHasTorch: boolean;
|
|
172
|
+
/**
|
|
173
|
+
* v0.13.2 — the `zoom` value to apply for the active lens in
|
|
174
|
+
* `multicam` mode (0.5× → ultra-wide end, 1× → wide baseline).
|
|
175
|
+
* `undefined` in standalone/wide-only modes (lens = device identity,
|
|
176
|
+
* no zoom needed). Pass to `<CameraView zoom>`.
|
|
177
|
+
*/
|
|
178
|
+
deviceZoom: number | undefined;
|
|
139
179
|
}
|
|
140
180
|
export declare function useCapture(options?: UseCaptureOptions): UseCaptureReturn;
|
|
141
181
|
//# sourceMappingURL=useCapture.d.ts.map
|