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
|
@@ -81,15 +81,21 @@ export function CaptureHeader({
|
|
|
81
81
|
colors,
|
|
82
82
|
style,
|
|
83
83
|
}: CaptureHeaderProps): React.JSX.Element {
|
|
84
|
-
|
|
84
|
+
// v0.13.1 — defaults are now transparent over the camera preview
|
|
85
|
+
// (matches the AR toggle / settings gear pill style); hosts using
|
|
86
|
+
// the header outside a camera context can pass solid colours via
|
|
87
|
+
// `colors`. Title + gear get a text shadow for legibility over
|
|
88
|
+
// bright preview content; guidance row keeps a translucent pill
|
|
89
|
+
// background for the same reason.
|
|
90
|
+
const bg = colors?.background ?? 'transparent';
|
|
85
91
|
const titleColor = colors?.title ?? '#ffffff';
|
|
86
92
|
const accent = colors?.accent ?? '#FF9F0A';
|
|
87
|
-
const guidanceBg = colors?.guidanceBackground ?? 'rgba(
|
|
93
|
+
const guidanceBg = colors?.guidanceBackground ?? 'rgba(0,0,0,0.45)';
|
|
88
94
|
const guidanceColor = colors?.guidanceText ?? '#ffffff';
|
|
89
95
|
|
|
90
96
|
return (
|
|
91
97
|
<View style={[{ backgroundColor: bg }, style]}>
|
|
92
|
-
<View style={[styles.titleRow, { paddingTop: topInset +
|
|
98
|
+
<View style={[styles.titleRow, { paddingTop: topInset + 4 }]}>
|
|
93
99
|
{onBack ? (
|
|
94
100
|
<Pressable
|
|
95
101
|
onPress={onBack}
|
|
@@ -98,7 +104,7 @@ export function CaptureHeader({
|
|
|
98
104
|
accessibilityLabel="Go back"
|
|
99
105
|
style={styles.backButton}
|
|
100
106
|
>
|
|
101
|
-
<Text style={[styles.backText, { color: accent }]}>
|
|
107
|
+
<Text style={[styles.backText, styles.textShadow, { color: accent }]}>
|
|
102
108
|
{backLabel}
|
|
103
109
|
</Text>
|
|
104
110
|
</Pressable>
|
|
@@ -107,7 +113,7 @@ export function CaptureHeader({
|
|
|
107
113
|
<View style={styles.backButton} />
|
|
108
114
|
)}
|
|
109
115
|
<Text
|
|
110
|
-
style={[styles.title, { color: titleColor }]}
|
|
116
|
+
style={[styles.title, styles.textShadow, { color: titleColor }]}
|
|
111
117
|
numberOfLines={1}
|
|
112
118
|
accessibilityRole="header"
|
|
113
119
|
>
|
|
@@ -123,7 +129,7 @@ export function CaptureHeader({
|
|
|
123
129
|
accessibilityLabel="Open panorama settings"
|
|
124
130
|
style={styles.backButton}
|
|
125
131
|
>
|
|
126
|
-
<Text style={[styles.gearIcon, { color: accent }]}>⚙</Text>
|
|
132
|
+
<Text style={[styles.gearIcon, styles.textShadow, { color: accent }]}>⚙</Text>
|
|
127
133
|
</Pressable>
|
|
128
134
|
) : (
|
|
129
135
|
<View style={styles.backButton} />
|
|
@@ -153,32 +159,49 @@ const styles = StyleSheet.create({
|
|
|
153
159
|
flexDirection: 'row',
|
|
154
160
|
alignItems: 'center',
|
|
155
161
|
justifyContent: 'space-between',
|
|
156
|
-
paddingHorizontal:
|
|
157
|
-
paddingBottom:
|
|
162
|
+
paddingHorizontal: 12,
|
|
163
|
+
paddingBottom: 4,
|
|
158
164
|
},
|
|
159
165
|
backButton: {
|
|
160
|
-
minWidth:
|
|
161
|
-
paddingVertical:
|
|
166
|
+
minWidth: 56,
|
|
167
|
+
paddingVertical: 2,
|
|
162
168
|
},
|
|
163
169
|
backText: {
|
|
164
|
-
fontSize:
|
|
170
|
+
fontSize: 14,
|
|
165
171
|
fontWeight: '500',
|
|
166
172
|
},
|
|
167
173
|
title: {
|
|
168
174
|
flex: 1,
|
|
169
175
|
textAlign: 'center',
|
|
170
|
-
fontSize:
|
|
176
|
+
fontSize: 14,
|
|
171
177
|
fontWeight: '600',
|
|
172
178
|
},
|
|
173
179
|
guidance: {
|
|
174
|
-
|
|
175
|
-
|
|
180
|
+
// v0.13.1 — guidance row is now a centred pill inset from the
|
|
181
|
+
// edges (matches the AR-toggle / lens-chip pill style) rather
|
|
182
|
+
// than a full-width band. The pill background gives it its
|
|
183
|
+
// own contrast over the preview without forcing a solid bar.
|
|
184
|
+
alignSelf: 'center',
|
|
185
|
+
marginTop: 4,
|
|
186
|
+
paddingHorizontal: 10,
|
|
187
|
+
paddingVertical: 5,
|
|
188
|
+
borderRadius: 12,
|
|
189
|
+
maxWidth: '90%',
|
|
176
190
|
},
|
|
177
191
|
guidanceText: {
|
|
178
|
-
fontSize:
|
|
192
|
+
fontSize: 12,
|
|
193
|
+
textAlign: 'center',
|
|
179
194
|
},
|
|
180
195
|
gearIcon: {
|
|
181
|
-
fontSize:
|
|
196
|
+
fontSize: 20,
|
|
182
197
|
textAlign: 'right',
|
|
183
198
|
},
|
|
199
|
+
// v0.13.1 — subtle text shadow so the (now-transparent) header
|
|
200
|
+
// text stays legible over bright preview content. Same trick
|
|
201
|
+
// iOS Camera uses for the timestamp / mode labels.
|
|
202
|
+
textShadow: {
|
|
203
|
+
textShadowColor: 'rgba(0,0,0,0.65)',
|
|
204
|
+
textShadowOffset: { width: 0, height: 1 },
|
|
205
|
+
textShadowRadius: 2,
|
|
206
|
+
},
|
|
184
207
|
});
|
|
@@ -109,6 +109,18 @@ export function CapturePreview({
|
|
|
109
109
|
animationType="fade"
|
|
110
110
|
transparent
|
|
111
111
|
statusBarTranslucent
|
|
112
|
+
// v0.13.1 — RN's iOS <Modal> defaults to portrait-only, which
|
|
113
|
+
// pins the stitched-image preview to portrait even when the host
|
|
114
|
+
// app is in landscape (the preview appeared sideways/letterboxed
|
|
115
|
+
// under a non-locked host). Declaring all four keeps the modal
|
|
116
|
+
// aligned with the interface. Mirrors the v0.12 fix already on
|
|
117
|
+
// OrientationDriftModal + PanoramaSettingsModal.
|
|
118
|
+
supportedOrientations={[
|
|
119
|
+
'portrait',
|
|
120
|
+
'portrait-upside-down',
|
|
121
|
+
'landscape-left',
|
|
122
|
+
'landscape-right',
|
|
123
|
+
]}
|
|
112
124
|
onRequestClose={onClose}
|
|
113
125
|
>
|
|
114
126
|
<View style={styles.backdrop}>
|
|
@@ -99,6 +99,28 @@ export interface CaptureThumbnailStripProps {
|
|
|
99
99
|
* stay under the strip's control to keep the count line consistent.
|
|
100
100
|
*/
|
|
101
101
|
style?: StyleProp<ViewStyle>;
|
|
102
|
+
/**
|
|
103
|
+
* v0.13.1 — when `true`, the strip stacks thumbnails VERTICALLY
|
|
104
|
+
* (column, scrolls up/down) instead of the default horizontal row.
|
|
105
|
+
* `<Camera>` sets this from the same `isSideEdge(homeIndicatorEdge)`
|
|
106
|
+
* signal that drives PanoramaBandOverlay's `vertical`, so under a
|
|
107
|
+
* non-locked host in landscape the idle capture strip stacks along
|
|
108
|
+
* the home-indicator edge like the live band does (rather than
|
|
109
|
+
* running horizontally across the middle of the rotated screen).
|
|
110
|
+
* Default `false` (legacy horizontal strip) — unchanged for
|
|
111
|
+
* portrait-locked hosts.
|
|
112
|
+
*/
|
|
113
|
+
vertical?: boolean;
|
|
114
|
+
/**
|
|
115
|
+
* v0.13.1 — counter-rotation applied to each thumbnail image so the
|
|
116
|
+
* captured scene reads upright when the device is held landscape
|
|
117
|
+
* under a PORTRAIT-LOCKED host (the JS framebuffer stays portrait, so
|
|
118
|
+
* the thumbnail would otherwise show 90° off). `<Camera>` passes the
|
|
119
|
+
* `useContentRotation()` result; `{}` (no-op) in upright cases.
|
|
120
|
+
* Applies only to the strip's own images — orientation of the strip's
|
|
121
|
+
* scroll axis is handled separately by `vertical`.
|
|
122
|
+
*/
|
|
123
|
+
contentRotation?: { transform?: ViewStyle['transform'] };
|
|
102
124
|
}
|
|
103
125
|
|
|
104
126
|
|
|
@@ -134,6 +156,8 @@ export function CaptureThumbnailStrip({
|
|
|
134
156
|
disablePreview = false,
|
|
135
157
|
onItemPress,
|
|
136
158
|
style,
|
|
159
|
+
vertical = false,
|
|
160
|
+
contentRotation,
|
|
137
161
|
}: CaptureThumbnailStripProps): React.JSX.Element {
|
|
138
162
|
// Built-in preview state — only used when the host hasn't
|
|
139
163
|
// provided its own onItemPress handler. Letting the host pass a
|
|
@@ -185,10 +209,13 @@ export function CaptureThumbnailStrip({
|
|
|
185
209
|
<View style={[styles.root, { backgroundColor }, style]}>
|
|
186
210
|
<FlatList
|
|
187
211
|
data={items}
|
|
188
|
-
horizontal
|
|
212
|
+
horizontal={!vertical}
|
|
189
213
|
showsHorizontalScrollIndicator={false}
|
|
214
|
+
showsVerticalScrollIndicator={false}
|
|
190
215
|
keyExtractor={(item) => item.id}
|
|
191
|
-
contentContainerStyle={
|
|
216
|
+
contentContainerStyle={
|
|
217
|
+
vertical ? styles.listContentVertical : styles.listContent
|
|
218
|
+
}
|
|
192
219
|
ListEmptyComponent={
|
|
193
220
|
<View
|
|
194
221
|
style={[styles.placeholder, { borderColor: textColor }]}
|
|
@@ -210,12 +237,15 @@ export function CaptureThumbnailStrip({
|
|
|
210
237
|
// re-created on every parent render.
|
|
211
238
|
style={[
|
|
212
239
|
styles.thumbWrapper,
|
|
240
|
+
// Spacing runs along the scroll axis: marginRight for the
|
|
241
|
+
// horizontal strip, marginBottom for the vertical column.
|
|
242
|
+
vertical ? styles.thumbWrapperVertical : styles.thumbWrapperHorizontal,
|
|
213
243
|
{ width: thumbWidth(item), height: THUMB_HEIGHT },
|
|
214
244
|
]}
|
|
215
245
|
>
|
|
216
246
|
<Image
|
|
217
247
|
source={{ uri: item.uri }}
|
|
218
|
-
style={styles.thumbImage}
|
|
248
|
+
style={[styles.thumbImage, contentRotation]}
|
|
219
249
|
resizeMode="cover"
|
|
220
250
|
/>
|
|
221
251
|
</Pressable>
|
|
@@ -246,12 +276,22 @@ const styles = StyleSheet.create({
|
|
|
246
276
|
paddingHorizontal: 12,
|
|
247
277
|
alignItems: 'center',
|
|
248
278
|
},
|
|
279
|
+
listContentVertical: {
|
|
280
|
+
paddingVertical: 12,
|
|
281
|
+
alignItems: 'center',
|
|
282
|
+
},
|
|
249
283
|
thumbWrapper: {
|
|
250
|
-
marginRight: 8,
|
|
251
284
|
borderRadius: 4,
|
|
252
285
|
overflow: 'hidden',
|
|
253
286
|
backgroundColor: '#222',
|
|
254
287
|
},
|
|
288
|
+
// Spacing applied along the scroll axis (see render site).
|
|
289
|
+
thumbWrapperHorizontal: {
|
|
290
|
+
marginRight: 8,
|
|
291
|
+
},
|
|
292
|
+
thumbWrapperVertical: {
|
|
293
|
+
marginBottom: 8,
|
|
294
|
+
},
|
|
255
295
|
thumbImage: {
|
|
256
296
|
width: '100%',
|
|
257
297
|
height: '100%',
|
|
@@ -198,6 +198,60 @@ interface Layout {
|
|
|
198
198
|
* reads as user-right-arrow (pointing along the horizontal pan
|
|
199
199
|
* direction).
|
|
200
200
|
*/
|
|
201
|
+
/**
|
|
202
|
+
* v0.13.1 — pure rotation-decision helpers, extracted for unit testing
|
|
203
|
+
* (the lib's jest config is pure-TS, no component mounting; see
|
|
204
|
+
* jest.config.js). These encode the orientation contract the band
|
|
205
|
+
* relies on, so a regression in the angles/branches is caught in CI
|
|
206
|
+
* rather than only on-device.
|
|
207
|
+
*
|
|
208
|
+
* `bandThumbRotation` — the CSS rotate transform that aligns a thumb's
|
|
209
|
+
* pixels with the band box. Returns the transform array RN expects, or
|
|
210
|
+
* `undefined` for "no rotation". Two regimes:
|
|
211
|
+
* - vertical=false (portrait-locked UI): the box is device-aligned, so
|
|
212
|
+
* a landscape device needs a 90° counter-rotation (CW for
|
|
213
|
+
* landscape-left, CCW for landscape-right).
|
|
214
|
+
* - vertical=true (non-locked, OS-rotated framebuffer): the screen
|
|
215
|
+
* rotation already did half the work, so the compensation is the
|
|
216
|
+
* OPPOSITE sign.
|
|
217
|
+
* Exported as `_bandThumbRotationForTests`.
|
|
218
|
+
*/
|
|
219
|
+
function bandThumbRotation(
|
|
220
|
+
orientation: BandCaptureOrientation,
|
|
221
|
+
vertical: boolean,
|
|
222
|
+
): Array<{ rotate: string }> | undefined {
|
|
223
|
+
if (vertical) {
|
|
224
|
+
if (orientation === 'landscape-left') return [{ rotate: '-90deg' }];
|
|
225
|
+
if (orientation === 'landscape-right') return [{ rotate: '90deg' }];
|
|
226
|
+
return undefined;
|
|
227
|
+
}
|
|
228
|
+
if (orientation === 'landscape-left') return [{ rotate: '90deg' }];
|
|
229
|
+
if (orientation === 'landscape-right') return [{ rotate: '-90deg' }];
|
|
230
|
+
return undefined;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* v0.13.1 — the rotation actually applied to the per-keyframe (multi-
|
|
235
|
+
* thumb) TILES. This is the EXIF double-rotation fix: the saved
|
|
236
|
+
* `keyframe-N.jpg` is sensor-native landscape + EXIF Orientation 6, which
|
|
237
|
+
* RN's <Image> already auto-rotates upright. So in the portrait-locked
|
|
238
|
+
* (vertical=false) path NO further transform is applied — adding one
|
|
239
|
+
* double-rotates (the original v0.12 bug). Only the non-locked
|
|
240
|
+
* (vertical=true) path needs the compensation. Returns `undefined` for
|
|
241
|
+
* "no transform". Exported as `_tileRotationForTests`.
|
|
242
|
+
*/
|
|
243
|
+
function tileRotation(
|
|
244
|
+
orientation: BandCaptureOrientation,
|
|
245
|
+
vertical: boolean,
|
|
246
|
+
): Array<{ rotate: string }> | undefined {
|
|
247
|
+
return vertical ? bandThumbRotation(orientation, vertical) : undefined;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** @internal test-only export — see `bandThumbRotation`. */
|
|
251
|
+
export const _bandThumbRotationForTests = bandThumbRotation;
|
|
252
|
+
/** @internal test-only export — see `tileRotation`. */
|
|
253
|
+
export const _tileRotationForTests = tileRotation;
|
|
254
|
+
|
|
201
255
|
function layoutFor(
|
|
202
256
|
orientation: BandCaptureOrientation,
|
|
203
257
|
vertical: boolean,
|
|
@@ -401,33 +455,13 @@ export function PanoramaBandOverlay({
|
|
|
401
455
|
// transform, so they appeared sideways in portrait-locked
|
|
402
456
|
// landscape captures (the case the example app's batch-keyframe
|
|
403
457
|
// engine hits).
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
//
|
|
412
|
-
// jsPortrait box + landscape device: box is device-aligned;
|
|
413
|
-
// image's "up" is at file-right (sensor convention). Rotate
|
|
414
|
-
// 90° CW (landscape-left) / 90° CCW (landscape-right) to
|
|
415
|
-
// align image up with box up.
|
|
416
|
-
// jsLandscape box + landscape device: box is user-aligned via
|
|
417
|
-
// OS screen rotation; image's "up" still at file-right. To
|
|
418
|
-
// align image up with box up, rotate the OPPOSITE direction
|
|
419
|
-
// from the jsPortrait case — the screen-rotation already
|
|
420
|
-
// handles half the work; we just need to compensate for the
|
|
421
|
-
// remaining mismatch.
|
|
422
|
-
if (vertical) {
|
|
423
|
-
if (resolvedOrientation === 'landscape-left') return [{ rotate: '-90deg' }];
|
|
424
|
-
if (resolvedOrientation === 'landscape-right') return [{ rotate: '90deg' }];
|
|
425
|
-
return undefined;
|
|
426
|
-
}
|
|
427
|
-
if (resolvedOrientation === 'landscape-left') return [{ rotate: '90deg' }];
|
|
428
|
-
if (resolvedOrientation === 'landscape-right') return [{ rotate: '-90deg' }];
|
|
429
|
-
return undefined;
|
|
430
|
-
}, [resolvedOrientation, vertical]);
|
|
458
|
+
// Rotation for the single cumulative thumb (panorama-*.jpg, a JFIF
|
|
459
|
+
// with NO EXIF tag → RN does not auto-rotate it, so the transform is
|
|
460
|
+
// always needed). See `bandThumbRotation` for the angle contract.
|
|
461
|
+
const thumbRotationTransform = useMemo(
|
|
462
|
+
() => bandThumbRotation(resolvedOrientation, vertical),
|
|
463
|
+
[resolvedOrientation, vertical],
|
|
464
|
+
);
|
|
431
465
|
|
|
432
466
|
const singleImageStyle = useMemo(
|
|
433
467
|
() =>
|
|
@@ -437,14 +471,42 @@ export function PanoramaBandOverlay({
|
|
|
437
471
|
[thumbRotationTransform],
|
|
438
472
|
);
|
|
439
473
|
|
|
440
|
-
//
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
474
|
+
// v0.13.1 — per-keyframe tile rotation is conditional on `vertical`.
|
|
475
|
+
//
|
|
476
|
+
// The keyframe JPEGs (`keyframe-N.jpg`) are saved as sensor-native
|
|
477
|
+
// landscape PIXELS *plus* an EXIF Orientation tag (= 6, "rotate 90°
|
|
478
|
+
// CW for display") — verified on-device: Android SM-A356U1 640×480
|
|
479
|
+
// + EXIF6, iOS iPhone16Pro 1920×1080 + EXIF6. RN's <Image> (Fresco
|
|
480
|
+
// on Android, ImageIO on iOS) HONORS EXIF and auto-rotates each tile
|
|
481
|
+
// to gravity-upright on its own. Whether a *further* JS transform is
|
|
482
|
+
// needed depends on the band box's coordinate frame:
|
|
483
|
+
//
|
|
484
|
+
// vertical=false (portrait-locked UI): box is in portrait JS coords,
|
|
485
|
+
// which align with the EXIF-upright tile → NO transform. Applying
|
|
486
|
+
// one here double-rotates (the original v0.12 bug — tiles appeared
|
|
487
|
+
// 90° off in portrait-locked landscape captures). Verified fixed
|
|
488
|
+
// on Android portrait-lock.
|
|
489
|
+
// vertical=true (non-locked host, device-landscape): box is in
|
|
490
|
+
// landscape JS coords, rotated 90° from the EXIF-upright tile →
|
|
491
|
+
// the counter-rotation is STILL required (verified on iOS: with no
|
|
492
|
+
// transform the tiles sit 90° off).
|
|
493
|
+
//
|
|
494
|
+
// So reuse `thumbRotationTransform` (which already encodes the correct
|
|
495
|
+
// per-orientation angle) ONLY in the vertical=true branch.
|
|
496
|
+
//
|
|
497
|
+
// The single cumulative thumb above always needs the transform: its
|
|
498
|
+
// source (`panorama-*.jpg`) is a JFIF with NO EXIF tag (verified:
|
|
499
|
+
// header ff d8 ff e0), so RN never auto-rotates it.
|
|
500
|
+
//
|
|
501
|
+
// Stitcher is unaffected — it reads `keyframe-N.jpg` with EXIF IGNORED
|
|
502
|
+
// (IMREAD_IGNORE_ORIENTATION) so it still gets the sensor-native
|
|
503
|
+
// pixels its pose intrinsics expect. Display-only.
|
|
504
|
+
const multiThumbStyle = useMemo(() => {
|
|
505
|
+
const tileTransform = tileRotation(resolvedOrientation, vertical);
|
|
506
|
+
return tileTransform
|
|
507
|
+
? [styles.multiThumb, { transform: tileTransform }]
|
|
508
|
+
: styles.multiThumb;
|
|
509
|
+
}, [resolvedOrientation, vertical]);
|
|
448
510
|
|
|
449
511
|
return (
|
|
450
512
|
<View pointerEvents="none" style={[styles.bandBase, layout.band]}>
|
|
@@ -88,6 +88,16 @@ export function PanoramaConfirmModal({
|
|
|
88
88
|
animationType="fade"
|
|
89
89
|
transparent
|
|
90
90
|
statusBarTranslucent
|
|
91
|
+
// v0.13.1 — RN's iOS <Modal> defaults to portrait-only. Declare
|
|
92
|
+
// all four so the confirm modal stays aligned with the interface
|
|
93
|
+
// under a non-locked host. Mirrors OrientationDriftModal +
|
|
94
|
+
// PanoramaSettingsModal (v0.12) and CapturePreview (v0.13.1).
|
|
95
|
+
supportedOrientations={[
|
|
96
|
+
'portrait',
|
|
97
|
+
'portrait-upside-down',
|
|
98
|
+
'landscape-left',
|
|
99
|
+
'landscape-right',
|
|
100
|
+
]}
|
|
91
101
|
onRequestClose={onDiscard}
|
|
92
102
|
>
|
|
93
103
|
<View style={styles.backdrop}>
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Unit tests for the band/tile orientation-decision functions in
|
|
4
|
+
* `PanoramaBandOverlay` — the pure logic behind the v0.13.1 EXIF
|
|
5
|
+
* double-rotation fix.
|
|
6
|
+
*
|
|
7
|
+
* Why test the pure functions, not a render: the lib's jest config is
|
|
8
|
+
* pure-TS (`ts-jest` + node env, no `@testing-library/react-native`;
|
|
9
|
+
* see jest.config.js header). The orientation contract lives entirely
|
|
10
|
+
* in `bandThumbRotation` / `tileRotation`, which the component now calls
|
|
11
|
+
* directly — so exercising them here covers the real code path.
|
|
12
|
+
*
|
|
13
|
+
* The bug these guard against:
|
|
14
|
+
* Saved `keyframe-N.jpg` files are sensor-native LANDSCAPE pixels with
|
|
15
|
+
* EXIF Orientation = 6 ("rotate 90° CW"). RN's <Image> auto-rotates
|
|
16
|
+
* them upright. v0.12 ALSO applied a JS rotate transform to the tiles
|
|
17
|
+
* → double-rotation → thumbnails 90° off in portrait-locked landscape.
|
|
18
|
+
* The fix: tiles get NO transform in the portrait-locked
|
|
19
|
+
* (vertical=false) path; the single cumulative thumb (no EXIF) still
|
|
20
|
+
* does.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
// Mock react-native so importing the SUT module doesn't pull the native
|
|
24
|
+
// StyleSheet/Image bridge (we only call the pure functions). Matches
|
|
25
|
+
// the mocking approach in useOrientationDrift.test.ts.
|
|
26
|
+
jest.mock('react-native', () => ({
|
|
27
|
+
Image: 'Image',
|
|
28
|
+
ScrollView: 'ScrollView',
|
|
29
|
+
StyleSheet: { create: (s: Record<string, unknown>) => s, absoluteFill: {} },
|
|
30
|
+
Text: 'Text',
|
|
31
|
+
View: 'View',
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
import {
|
|
35
|
+
_bandThumbRotationForTests as bandThumbRotation,
|
|
36
|
+
_tileRotationForTests as tileRotation,
|
|
37
|
+
type BandCaptureOrientation,
|
|
38
|
+
} from '../PanoramaBandOverlay';
|
|
39
|
+
|
|
40
|
+
const PORTRAIT: BandCaptureOrientation = 'portrait';
|
|
41
|
+
const UPSIDE: BandCaptureOrientation = 'portrait-upside-down';
|
|
42
|
+
const LEFT: BandCaptureOrientation = 'landscape-left';
|
|
43
|
+
const RIGHT: BandCaptureOrientation = 'landscape-right';
|
|
44
|
+
|
|
45
|
+
describe('bandThumbRotation — single cumulative thumb (no EXIF source)', () => {
|
|
46
|
+
describe('vertical=false (portrait-locked UI)', () => {
|
|
47
|
+
it('does not rotate in portrait', () => {
|
|
48
|
+
expect(bandThumbRotation(PORTRAIT, false)).toBeUndefined();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('does not rotate in portrait-upside-down', () => {
|
|
52
|
+
expect(bandThumbRotation(UPSIDE, false)).toBeUndefined();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('rotates 90° CW for landscape-left', () => {
|
|
56
|
+
expect(bandThumbRotation(LEFT, false)).toEqual([{ rotate: '90deg' }]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('rotates 90° CCW for landscape-right (opposite sign of left)', () => {
|
|
60
|
+
expect(bandThumbRotation(RIGHT, false)).toEqual([{ rotate: '-90deg' }]);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('vertical=true (non-locked, OS-rotated framebuffer)', () => {
|
|
65
|
+
it('does not rotate in portrait', () => {
|
|
66
|
+
expect(bandThumbRotation(PORTRAIT, true)).toBeUndefined();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('uses the OPPOSITE sign from the portrait-locked case (left)', () => {
|
|
70
|
+
// vertical=false → 90deg, so vertical=true → -90deg.
|
|
71
|
+
expect(bandThumbRotation(LEFT, true)).toEqual([{ rotate: '-90deg' }]);
|
|
72
|
+
expect(bandThumbRotation(LEFT, true)).not.toEqual(
|
|
73
|
+
bandThumbRotation(LEFT, false),
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('uses the OPPOSITE sign from the portrait-locked case (right)', () => {
|
|
78
|
+
expect(bandThumbRotation(RIGHT, true)).toEqual([{ rotate: '90deg' }]);
|
|
79
|
+
expect(bandThumbRotation(RIGHT, true)).not.toEqual(
|
|
80
|
+
bandThumbRotation(RIGHT, false),
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('tileRotation — per-keyframe tiles (EXIF-6 source, the fix)', () => {
|
|
87
|
+
describe('vertical=false (portrait-locked) — the regression case', () => {
|
|
88
|
+
it.each<[BandCaptureOrientation]>([
|
|
89
|
+
[PORTRAIT],
|
|
90
|
+
[UPSIDE],
|
|
91
|
+
[LEFT],
|
|
92
|
+
[RIGHT],
|
|
93
|
+
])(
|
|
94
|
+
'applies NO transform for %s (EXIF already auto-rotates → no double-rotate)',
|
|
95
|
+
(orientation) => {
|
|
96
|
+
expect(tileRotation(orientation, false)).toBeUndefined();
|
|
97
|
+
},
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
it('specifically does NOT rotate landscape tiles (the v0.12 bug)', () => {
|
|
101
|
+
// Pre-fix this returned [{rotate:'90deg'}] / [{rotate:'-90deg'}]
|
|
102
|
+
// on top of the EXIF auto-rotate → tiles 90° off. Must be undefined.
|
|
103
|
+
expect(tileRotation(LEFT, false)).toBeUndefined();
|
|
104
|
+
expect(tileRotation(RIGHT, false)).toBeUndefined();
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('vertical=true (non-locked landscape) — transform still needed', () => {
|
|
109
|
+
it('matches bandThumbRotation in the vertical path', () => {
|
|
110
|
+
// In the OS-rotated case the box is landscape JS coords, 90° off
|
|
111
|
+
// the EXIF-upright tile, so the compensation IS required.
|
|
112
|
+
expect(tileRotation(LEFT, true)).toEqual(bandThumbRotation(LEFT, true));
|
|
113
|
+
expect(tileRotation(RIGHT, true)).toEqual(bandThumbRotation(RIGHT, true));
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('does not rotate in portrait even when vertical', () => {
|
|
117
|
+
expect(tileRotation(PORTRAIT, true)).toBeUndefined();
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Unit tests for `homeIndicatorEdge` + `isSideEdge` — the pure functions
|
|
4
|
+
* that produce the `vertical` flag driving PanoramaBandOverlay and
|
|
5
|
+
* CaptureThumbnailStrip layout under non-locked hosts.
|
|
6
|
+
*
|
|
7
|
+
* vertical = isSideEdge(homeIndicatorEdge(jsLandscape, deviceOrient))
|
|
8
|
+
*
|
|
9
|
+
* Contract (v0.12 orientation-aware Camera):
|
|
10
|
+
* - Portrait JS layout (jsLandscape=false) → 'bottom' edge → NOT a
|
|
11
|
+
* side edge → vertical=false (horizontal strip, the portrait-locked
|
|
12
|
+
* case that's the recommended config).
|
|
13
|
+
* - Landscape JS layout → 'right'/'left' edge → side edge →
|
|
14
|
+
* vertical=true (the strip/band stack along the home-indicator edge).
|
|
15
|
+
*
|
|
16
|
+
* Pure-TS test per jest.config.js (no component mount). The functions
|
|
17
|
+
* are imported via Camera.tsx's `_*ForTests` handles; react-native and
|
|
18
|
+
* the heavy native deps are mocked so the import resolves in node env.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
// The SUT lives in Camera.tsx, which transitively imports the entire
|
|
22
|
+
// camera surface (vision-camera, worklets, sensors, native modules).
|
|
23
|
+
// We only call two pure functions, so stub the whole dependency tree.
|
|
24
|
+
jest.mock('react-native', () => ({
|
|
25
|
+
NativeModules: {},
|
|
26
|
+
Platform: { OS: 'ios', select: (o: Record<string, unknown>) => o.ios },
|
|
27
|
+
Pressable: 'Pressable',
|
|
28
|
+
StyleSheet: { create: (s: Record<string, unknown>) => s, absoluteFill: {} },
|
|
29
|
+
Text: 'Text',
|
|
30
|
+
View: 'View',
|
|
31
|
+
Image: 'Image',
|
|
32
|
+
ScrollView: 'ScrollView',
|
|
33
|
+
Animated: { View: 'Animated.View', Value: class {}, timing: () => ({ start: () => undefined }) },
|
|
34
|
+
Modal: 'Modal',
|
|
35
|
+
ActivityIndicator: 'ActivityIndicator',
|
|
36
|
+
useWindowDimensions: () => ({ width: 0, height: 0 }),
|
|
37
|
+
requireNativeComponent: () => 'NativeComponent',
|
|
38
|
+
UIManager: { getViewManagerConfig: () => ({}) },
|
|
39
|
+
findNodeHandle: () => 1,
|
|
40
|
+
}));
|
|
41
|
+
jest.mock('react-native-safe-area-context', () => ({
|
|
42
|
+
useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }),
|
|
43
|
+
}));
|
|
44
|
+
jest.mock('react-native-sensors', () => ({
|
|
45
|
+
accelerometer: { subscribe: jest.fn(() => ({ unsubscribe: jest.fn() })) },
|
|
46
|
+
setUpdateIntervalForType: jest.fn(),
|
|
47
|
+
SensorTypes: { accelerometer: 'accelerometer' },
|
|
48
|
+
}));
|
|
49
|
+
jest.mock('react-native-worklets-core', () => ({ Worklets: {} }));
|
|
50
|
+
jest.mock('react-native-vision-camera', () => ({
|
|
51
|
+
Camera: 'Camera',
|
|
52
|
+
useCameraDevice: jest.fn(),
|
|
53
|
+
useCameraPermission: jest.fn(),
|
|
54
|
+
}));
|
|
55
|
+
|
|
56
|
+
import {
|
|
57
|
+
_homeIndicatorEdgeForTests as homeIndicatorEdge,
|
|
58
|
+
_isSideEdgeForTests as isSideEdge,
|
|
59
|
+
} from '../Camera';
|
|
60
|
+
import type { DeviceOrientation } from '../useDeviceOrientation';
|
|
61
|
+
|
|
62
|
+
const PORTRAIT: DeviceOrientation = 'portrait';
|
|
63
|
+
const UPSIDE: DeviceOrientation = 'portrait-upside-down';
|
|
64
|
+
const LEFT: DeviceOrientation = 'landscape-left';
|
|
65
|
+
const RIGHT: DeviceOrientation = 'landscape-right';
|
|
66
|
+
|
|
67
|
+
// The composed signal the band/strip actually consume.
|
|
68
|
+
const vertical = (jsLandscape: boolean, o: DeviceOrientation) =>
|
|
69
|
+
isSideEdge(homeIndicatorEdge(jsLandscape, o));
|
|
70
|
+
|
|
71
|
+
describe('homeIndicatorEdge', () => {
|
|
72
|
+
it('returns bottom for any portrait JS layout (jsLandscape=false)', () => {
|
|
73
|
+
// Portrait JS layout always anchors bottom regardless of the sensor
|
|
74
|
+
// value — this is the portrait-locked case (the recommended config).
|
|
75
|
+
for (const o of [PORTRAIT, UPSIDE, LEFT, RIGHT]) {
|
|
76
|
+
expect(homeIndicatorEdge(false, o)).toBe('bottom');
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('anchors RIGHT for landscape-left device in landscape JS layout', () => {
|
|
81
|
+
expect(homeIndicatorEdge(true, LEFT)).toBe('right');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('anchors LEFT for landscape-right device in landscape JS layout', () => {
|
|
85
|
+
expect(homeIndicatorEdge(true, RIGHT)).toBe('left');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('falls through to right for non-landscape sensor + landscape JS (transient)', () => {
|
|
89
|
+
// jsLandscape=true with a portrait sensor reading only happens
|
|
90
|
+
// mid-rotation; defensive default is 'right'.
|
|
91
|
+
expect(homeIndicatorEdge(true, PORTRAIT)).toBe('right');
|
|
92
|
+
expect(homeIndicatorEdge(true, UPSIDE)).toBe('right');
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('isSideEdge', () => {
|
|
97
|
+
it('is true only for left/right edges', () => {
|
|
98
|
+
expect(isSideEdge('left')).toBe(true);
|
|
99
|
+
expect(isSideEdge('right')).toBe(true);
|
|
100
|
+
expect(isSideEdge('bottom')).toBe(false);
|
|
101
|
+
expect(isSideEdge('top')).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe('vertical flag (composed) — what the strip/band consume', () => {
|
|
106
|
+
it('is FALSE for portrait-locked layout (horizontal strip, recommended)', () => {
|
|
107
|
+
for (const o of [PORTRAIT, UPSIDE, LEFT, RIGHT]) {
|
|
108
|
+
expect(vertical(false, o)).toBe(false);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('is TRUE for both landscape orientations under a non-locked host', () => {
|
|
113
|
+
expect(vertical(true, LEFT)).toBe(true);
|
|
114
|
+
expect(vertical(true, RIGHT)).toBe(true);
|
|
115
|
+
});
|
|
116
|
+
});
|