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.
Files changed (39) hide show
  1. package/CHANGELOG.md +181 -0
  2. package/README.md +33 -17
  3. package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +33 -5
  4. package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +73 -1
  5. package/dist/camera/Camera.d.ts +226 -0
  6. package/dist/camera/Camera.js +208 -20
  7. package/dist/camera/CameraView.d.ts +6 -0
  8. package/dist/camera/CameraView.js +2 -2
  9. package/dist/camera/CaptureHeader.js +39 -16
  10. package/dist/camera/CapturePreview.js +13 -1
  11. package/dist/camera/CaptureThumbnailStrip.d.ts +25 -1
  12. package/dist/camera/CaptureThumbnailStrip.js +17 -4
  13. package/dist/camera/PanoramaBandOverlay.d.ts +76 -0
  14. package/dist/camera/PanoramaBandOverlay.js +90 -33
  15. package/dist/camera/PanoramaConfirmModal.js +11 -1
  16. package/dist/camera/selectCaptureDevice.d.ts +93 -0
  17. package/dist/camera/selectCaptureDevice.js +131 -0
  18. package/dist/camera/useCapture.d.ts +40 -0
  19. package/dist/camera/useCapture.js +50 -12
  20. package/dist/camera/useContentRotation.d.ts +99 -0
  21. package/dist/camera/useContentRotation.js +124 -0
  22. package/dist/index.d.ts +1 -3
  23. package/dist/index.js +6 -5
  24. package/package.json +1 -1
  25. package/src/camera/Camera.tsx +546 -32
  26. package/src/camera/CameraView.tsx +9 -0
  27. package/src/camera/CaptureHeader.tsx +39 -16
  28. package/src/camera/CapturePreview.tsx +12 -0
  29. package/src/camera/CaptureThumbnailStrip.tsx +44 -4
  30. package/src/camera/PanoramaBandOverlay.tsx +97 -35
  31. package/src/camera/PanoramaConfirmModal.tsx +10 -0
  32. package/src/camera/__tests__/bandThumbRotation.test.ts +120 -0
  33. package/src/camera/__tests__/homeIndicatorEdge.test.ts +116 -0
  34. package/src/camera/__tests__/selectCaptureDevice.test.ts +177 -0
  35. package/src/camera/__tests__/useContentRotation.test.ts +89 -0
  36. package/src/camera/selectCaptureDevice.ts +187 -0
  37. package/src/camera/useCapture.ts +99 -11
  38. package/src/camera/useContentRotation.ts +149 -0
  39. package/src/index.ts +6 -2
@@ -33,6 +33,12 @@ export interface CameraViewProps {
33
33
  device: CameraDevice | null | undefined;
34
34
  /** Flash / torch state from ``useCapture().flash``. */
35
35
  flash?: 'off' | 'on';
36
+ /**
37
+ * v0.13.2 — zoom factor for the mounted device. Used in multi-cam
38
+ * mode to switch lenses (0.5× ultra-wide ↔ 1× wide) on a single
39
+ * device. `undefined` leaves vision-camera at its default zoom.
40
+ */
41
+ zoom?: number;
36
42
  /** Whether the preview is actively rendering. Defaults to true. */
37
43
  isActive?: boolean;
38
44
  /**
@@ -103,6 +109,7 @@ export const CameraView = forwardRef<Camera | null, CameraViewProps>(function Ca
103
109
  {
104
110
  device,
105
111
  flash = 'off',
112
+ zoom,
106
113
  isActive = true,
107
114
  video = false,
108
115
  guidance,
@@ -150,6 +157,8 @@ export const CameraView = forwardRef<Camera | null, CameraViewProps>(function Ca
150
157
  isActive={isActive}
151
158
  photo
152
159
  video={video}
160
+ // v0.13.2 — multi-cam lens switch via zoom (undefined = default).
161
+ {...(zoom != null ? { zoom } : {})}
153
162
  // Bake the device orientation into the captured pixels.
154
163
  // Without this, vision-camera writes the file in the camera
155
164
  // sensor's native landscape and relies on EXIF metadata to
@@ -81,15 +81,21 @@ export function CaptureHeader({
81
81
  colors,
82
82
  style,
83
83
  }: CaptureHeaderProps): React.JSX.Element {
84
- const bg = colors?.background ?? '#000000';
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(255,255,255,0.08)';
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 + 8 }]}>
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: 16,
157
- paddingBottom: 8,
162
+ paddingHorizontal: 12,
163
+ paddingBottom: 4,
158
164
  },
159
165
  backButton: {
160
- minWidth: 64,
161
- paddingVertical: 4,
166
+ minWidth: 56,
167
+ paddingVertical: 2,
162
168
  },
163
169
  backText: {
164
- fontSize: 16,
170
+ fontSize: 14,
165
171
  fontWeight: '500',
166
172
  },
167
173
  title: {
168
174
  flex: 1,
169
175
  textAlign: 'center',
170
- fontSize: 16,
176
+ fontSize: 14,
171
177
  fontWeight: '600',
172
178
  },
173
179
  guidance: {
174
- paddingHorizontal: 16,
175
- paddingVertical: 8,
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: 13,
192
+ fontSize: 12,
193
+ textAlign: 'center',
179
194
  },
180
195
  gearIcon: {
181
- fontSize: 22,
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={styles.listContent}
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
- const thumbRotationTransform = useMemo<
405
- Array<{ rotate: string }> | undefined
406
- >(() => {
407
- // Empirical observation (on-device test 2026-05-28): captured
408
- // per-keyframe JPEGs ARE saved in sensor-native landscape (not
409
- // user-perspective), despite the cumulative panorama getting
410
- // device-orientation rotation via finalize(). So:
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
- // Same rotation applied to the per-keyframe (multi-thumb) tiles.
441
- const multiThumbStyle = useMemo(
442
- () =>
443
- thumbRotationTransform
444
- ? [styles.multiThumb, { transform: thumbRotationTransform }]
445
- : styles.multiThumb,
446
- [thumbRotationTransform],
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
+ });