react-native-image-stitcher 0.11.1 → 0.12.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 +75 -0
- package/README.md +28 -0
- package/android/src/main/java/io/imagestitcher/rn/IncrementalFirstwinsEngine.kt +3 -2
- package/dist/camera/ARCameraView.d.ts +10 -0
- package/dist/camera/ARCameraView.js +1 -0
- package/dist/camera/Camera.d.ts +20 -0
- package/dist/camera/Camera.js +175 -6
- package/dist/camera/OrientationDriftModal.d.ts +83 -0
- package/dist/camera/OrientationDriftModal.js +159 -0
- package/dist/camera/PanoramaBandOverlay.d.ts +13 -1
- package/dist/camera/PanoramaBandOverlay.js +106 -45
- package/dist/camera/PanoramaSettingsModal.js +15 -1
- package/dist/camera/ViewportCropOverlay.d.ts +35 -31
- package/dist/camera/ViewportCropOverlay.js +39 -30
- package/dist/camera/useDeviceOrientation.d.ts +18 -9
- package/dist/camera/useDeviceOrientation.js +18 -9
- package/dist/camera/useOrientationDrift.d.ts +104 -0
- package/dist/camera/useOrientationDrift.js +120 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +12 -1
- package/dist/stitching/incremental.d.ts +5 -3
- package/ios/Sources/RNImageStitcher/ARSessionBridge.swift +7 -1
- package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.h +4 -3
- package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.mm +9 -7
- package/ios/Sources/RNImageStitcher/RNSARSession.swift +46 -7
- package/package.json +1 -1
- package/src/camera/ARCameraView.tsx +18 -1
- package/src/camera/Camera.tsx +280 -13
- package/src/camera/OrientationDriftModal.tsx +224 -0
- package/src/camera/PanoramaBandOverlay.tsx +135 -49
- package/src/camera/PanoramaSettingsModal.tsx +14 -0
- package/src/camera/ViewportCropOverlay.tsx +52 -30
- package/src/camera/__tests__/useOrientationDrift.test.ts +169 -0
- package/src/camera/useDeviceOrientation.ts +18 -9
- package/src/camera/useOrientationDrift.ts +172 -0
- package/src/index.ts +13 -0
- package/src/stitching/incremental.ts +5 -3
|
@@ -87,6 +87,18 @@ export type BandCaptureOrientation =
|
|
|
87
87
|
| 'landscape-right';
|
|
88
88
|
|
|
89
89
|
export interface PanoramaBandOverlayProps {
|
|
90
|
+
/**
|
|
91
|
+
* v0.12.0 — `true` when the band should render as a vertical
|
|
92
|
+
* column in JS (anchor edge is JS-left or JS-right, i.e.
|
|
93
|
+
* non-locked host with device-landscape). `false` (default)
|
|
94
|
+
* renders the legacy horizontal strip — covers portrait-locked
|
|
95
|
+
* hosts in any device orientation AND non-locked hosts in
|
|
96
|
+
* portrait. The flagship `<Camera>` derives this from
|
|
97
|
+
* `useWindowDimensions()` + `useDeviceOrientation()` (see
|
|
98
|
+
* `homeIndicatorEdge` in `Camera.tsx`); Layer-2 hosts pass it
|
|
99
|
+
* directly.
|
|
100
|
+
*/
|
|
101
|
+
vertical?: boolean;
|
|
90
102
|
/**
|
|
91
103
|
* Latest engine state. Pass `useIncrementalStitcher().state`.
|
|
92
104
|
* Used for single-thumb fallback URI and fill-ratio when no
|
|
@@ -138,8 +150,12 @@ interface Layout {
|
|
|
138
150
|
kind: LayoutKind;
|
|
139
151
|
/** Outer container style — positioning + flexDirection. */
|
|
140
152
|
band: ViewStyle;
|
|
141
|
-
/**
|
|
142
|
-
|
|
153
|
+
/**
|
|
154
|
+
* Direction used by both the outer band AND the scroll content.
|
|
155
|
+
* row/row-reverse for horizontal bands; column/column-reverse for
|
|
156
|
+
* vertical bands (non-locked host in landscape, jsLandscape=true).
|
|
157
|
+
*/
|
|
158
|
+
flexDirection: 'row' | 'row-reverse' | 'column' | 'column-reverse';
|
|
143
159
|
/** Unicode arrow pointing along the user-perceived pan axis. */
|
|
144
160
|
arrowGlyph: string;
|
|
145
161
|
}
|
|
@@ -182,26 +198,53 @@ interface Layout {
|
|
|
182
198
|
* reads as user-right-arrow (pointing along the horizontal pan
|
|
183
199
|
* direction).
|
|
184
200
|
*/
|
|
185
|
-
function layoutFor(
|
|
201
|
+
function layoutFor(
|
|
202
|
+
orientation: BandCaptureOrientation,
|
|
203
|
+
vertical: boolean,
|
|
204
|
+
): Layout {
|
|
186
205
|
const commonInner: ViewStyle = {
|
|
187
206
|
alignItems: 'center',
|
|
188
207
|
paddingHorizontal: BAND_PADDING,
|
|
189
208
|
paddingVertical: BAND_PADDING,
|
|
190
209
|
backgroundColor: 'rgba(0, 0, 0, 0.55)',
|
|
191
210
|
};
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
// in
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
//
|
|
211
|
+
// v0.12.0 — band structural orientation tracks the host's
|
|
212
|
+
// `vertical` flag (which the host derives from JS layout
|
|
213
|
+
// orientation):
|
|
214
|
+
//
|
|
215
|
+
// vertical=false Horizontal strip in JS coords. Under
|
|
216
|
+
// portrait-lock + device-landscape this appears
|
|
217
|
+
// as a vertical column on user-right via the
|
|
218
|
+
// un-rotated framebuffer.
|
|
219
|
+
// vertical=true Vertical column in JS coords. Non-locked
|
|
220
|
+
// + device-landscape — band lives along the
|
|
221
|
+
// JS-side strip where the home indicator is.
|
|
200
222
|
//
|
|
201
|
-
// What still varies by physical orientation: the
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
|
|
223
|
+
// What still varies by physical orientation regardless: the
|
|
224
|
+
// thumbnail flow direction so newest sits at the user-perceived
|
|
225
|
+
// pan-leading edge (flexDirection + arrowGlyph).
|
|
226
|
+
if (vertical) {
|
|
227
|
+
// Vertical band in JS coords (non-locked landscape). The OS
|
|
228
|
+
// rotated the framebuffer so user-top = JS-top, user-bottom =
|
|
229
|
+
// JS-bottom — same scroll direction regardless of whether the
|
|
230
|
+
// device is landscape-left or landscape-right. Latest grows
|
|
231
|
+
// toward user-bottom (= JS-bottom). flexDirection 'column'
|
|
232
|
+
// puts array[0]/oldest at JS-top.
|
|
233
|
+
return {
|
|
234
|
+
kind: 'landscape',
|
|
235
|
+
band: {
|
|
236
|
+
marginHorizontal: 8,
|
|
237
|
+
marginVertical: 16,
|
|
238
|
+
width: BAND_THICKNESS,
|
|
239
|
+
flexDirection: 'column',
|
|
240
|
+
...commonInner,
|
|
241
|
+
},
|
|
242
|
+
flexDirection: 'column',
|
|
243
|
+
arrowGlyph: '↓',
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
// vertical=false branch: pre-v0.12 horizontal-strip behavior
|
|
247
|
+
// keyed on device-physical orientation for thumbnail direction.
|
|
205
248
|
if (orientation === 'landscape-left') {
|
|
206
249
|
// Phone rotated 90° CCW from portrait (home indicator on the
|
|
207
250
|
// user's RIGHT). With UI orientation-locked to portrait:
|
|
@@ -268,6 +311,7 @@ export function PanoramaBandOverlay({
|
|
|
268
311
|
state,
|
|
269
312
|
frameUris,
|
|
270
313
|
captureOrientation,
|
|
314
|
+
vertical = false,
|
|
271
315
|
}: PanoramaBandOverlayProps): React.JSX.Element | null {
|
|
272
316
|
// 2026-05-18 (Issue #3 fix) — orientation source priority:
|
|
273
317
|
// 1. `captureOrientation` prop from the host (4-way; correct
|
|
@@ -280,8 +324,8 @@ export function PanoramaBandOverlay({
|
|
|
280
324
|
captureOrientation
|
|
281
325
|
?? (state?.isLandscape ? 'landscape-left' : 'portrait');
|
|
282
326
|
const layout = useMemo(
|
|
283
|
-
() => layoutFor(resolvedOrientation),
|
|
284
|
-
[resolvedOrientation],
|
|
327
|
+
() => layoutFor(resolvedOrientation, vertical),
|
|
328
|
+
[resolvedOrientation, vertical],
|
|
285
329
|
);
|
|
286
330
|
|
|
287
331
|
const scrollRef = useRef<ScrollView | null>(null);
|
|
@@ -299,24 +343,21 @@ export function PanoramaBandOverlay({
|
|
|
299
343
|
|
|
300
344
|
const hasMultiThumb = cappedFrameUris.length > 0;
|
|
301
345
|
|
|
302
|
-
// Auto-scroll on content-size change.
|
|
303
|
-
//
|
|
304
|
-
//
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
// scrollTo({x: 0}) shows it. The earlier always-scrollToEnd
|
|
309
|
-
// behaviour scrolled to OLDEST in row-reverse, which hid the
|
|
310
|
-
// just-captured frame off-screen at user-bottom.
|
|
346
|
+
// Auto-scroll on content-size change. `*-reverse` puts latest at
|
|
347
|
+
// scroll origin (scrollTo {0,0}); normal `row`/`column` puts
|
|
348
|
+
// latest at scroll end (scrollToEnd).
|
|
349
|
+
const isReverse =
|
|
350
|
+
layout.flexDirection === 'row-reverse' ||
|
|
351
|
+
layout.flexDirection === 'column-reverse';
|
|
311
352
|
const onContentSizeChange = useCallback(() => {
|
|
312
353
|
const sv = scrollRef.current;
|
|
313
354
|
if (!sv) return;
|
|
314
|
-
if (
|
|
355
|
+
if (isReverse) {
|
|
315
356
|
sv.scrollTo({ x: 0, y: 0, animated: false });
|
|
316
357
|
} else {
|
|
317
358
|
sv.scrollToEnd({ animated: false });
|
|
318
359
|
}
|
|
319
|
-
}, [
|
|
360
|
+
}, [isReverse]);
|
|
320
361
|
|
|
321
362
|
// ── Single cumulative thumbnail (live-engine fallback) ──────────
|
|
322
363
|
//
|
|
@@ -339,27 +380,70 @@ export function PanoramaBandOverlay({
|
|
|
339
380
|
return Math.max(SINGLE_THUMB_INNER, SINGLE_THUMB_MAX_PAN_LEN * fillRatio);
|
|
340
381
|
}, [fillRatio]);
|
|
341
382
|
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
//
|
|
345
|
-
//
|
|
346
|
-
//
|
|
383
|
+
// Image rotation transform for thumbnails. Captured frames are in
|
|
384
|
+
// user-perspective orientation (the capture pipeline rotates the
|
|
385
|
+
// sensor-native bytes via `outputOrientation="device"` + EXIF
|
|
386
|
+
// baking in `normaliseOrientation`). The thumbnail BOX is in
|
|
387
|
+
// JS coords. When JS coords are device-aligned (portrait-lock,
|
|
388
|
+
// i.e. vertical=false here) and the device is in landscape, the
|
|
389
|
+
// image content is rotated 90° from the box's axes → appears
|
|
390
|
+
// sideways without compensation. Apply a counter-rotation to
|
|
391
|
+
// line content up with the box's perceived "top".
|
|
347
392
|
//
|
|
348
|
-
//
|
|
349
|
-
//
|
|
350
|
-
//
|
|
351
|
-
//
|
|
393
|
+
// When vertical=true (non-locked + device-landscape; JS coords
|
|
394
|
+
// rotated with screen), the box IS user-aligned already. No
|
|
395
|
+
// rotation needed — the image is already correctly oriented for
|
|
396
|
+
// direct display.
|
|
397
|
+
//
|
|
398
|
+
// V12.14.9 → v0.12.0 — extended from single-thumb (cumulative
|
|
399
|
+
// panorama image fallback) to the multi-thumb path too. Pre-
|
|
400
|
+
// v0.12 the multi-thumb keyframe thumbnails had no rotation
|
|
401
|
+
// transform, so they appeared sideways in portrait-locked
|
|
402
|
+
// landscape captures (the case the example app's batch-keyframe
|
|
403
|
+
// 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]);
|
|
431
|
+
|
|
352
432
|
const singleImageStyle = useMemo(
|
|
353
|
-
() =>
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
433
|
+
() =>
|
|
434
|
+
thumbRotationTransform
|
|
435
|
+
? [StyleSheet.absoluteFill, { transform: thumbRotationTransform }]
|
|
436
|
+
: StyleSheet.absoluteFill,
|
|
437
|
+
[thumbRotationTransform],
|
|
438
|
+
);
|
|
439
|
+
|
|
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],
|
|
363
447
|
);
|
|
364
448
|
|
|
365
449
|
return (
|
|
@@ -378,7 +462,9 @@ export function PanoramaBandOverlay({
|
|
|
378
462
|
// looked detached when there were only a few thumbnails.
|
|
379
463
|
<ScrollView
|
|
380
464
|
ref={scrollRef}
|
|
381
|
-
|
|
465
|
+
// Horizontal scroll in JS-portrait bands; vertical scroll
|
|
466
|
+
// in JS-landscape (non-locked host) bands.
|
|
467
|
+
horizontal={layout.kind === 'portrait'}
|
|
382
468
|
showsHorizontalScrollIndicator={false}
|
|
383
469
|
showsVerticalScrollIndicator={false}
|
|
384
470
|
style={styles.thumbScroll}
|
|
@@ -395,7 +481,7 @@ export function PanoramaBandOverlay({
|
|
|
395
481
|
// defensive). URI segment helps RN's image cache key.
|
|
396
482
|
key={`${idx}-${uri}`}
|
|
397
483
|
source={{ uri }}
|
|
398
|
-
style={
|
|
484
|
+
style={multiThumbStyle}
|
|
399
485
|
resizeMode="cover"
|
|
400
486
|
fadeDuration={0}
|
|
401
487
|
/>
|
|
@@ -164,6 +164,20 @@ export function PanoramaSettingsModal({
|
|
|
164
164
|
transparent
|
|
165
165
|
statusBarTranslucent
|
|
166
166
|
onRequestClose={onClose}
|
|
167
|
+
// v0.12.0 — RN's iOS Modal defaults to portrait-only. When a
|
|
168
|
+
// host removes its UIInterfaceOrientations portrait lock to
|
|
169
|
+
// support landscape capture, opening this modal while in
|
|
170
|
+
// landscape would force iOS to rotate the window scene to
|
|
171
|
+
// portrait, then the underlying <Camera>'s ARSession can end
|
|
172
|
+
// up with stale display-transform state on dismiss (preview
|
|
173
|
+
// renders sideways). Declaring all orientations keeps the
|
|
174
|
+
// window aligned with the device throughout the modal cycle.
|
|
175
|
+
supportedOrientations={[
|
|
176
|
+
'portrait',
|
|
177
|
+
'portrait-upside-down',
|
|
178
|
+
'landscape-left',
|
|
179
|
+
'landscape-right',
|
|
180
|
+
]}
|
|
167
181
|
>
|
|
168
182
|
<View style={styles.backdrop}>
|
|
169
183
|
<View style={styles.sheet}>
|
|
@@ -1,43 +1,49 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
2
|
/**
|
|
3
|
-
* ViewportCropOverlay — V12.12.
|
|
3
|
+
* ViewportCropOverlay — V12.12 + v0.12.0 orientation-aware (R2-lite).
|
|
4
4
|
*
|
|
5
5
|
* Translucent dim bars on the camera preview's PAN-AXIS edges
|
|
6
|
-
* showing where the panorama engine's source-crop is.
|
|
7
|
-
*
|
|
8
|
-
* the engine clipped the long sensor axis (perpendicular to pan
|
|
9
|
-
* in landscape, along pan in portrait) — that produced visible
|
|
10
|
-
* bars on the user-LEFT/RIGHT in landscape, which is the WRONG
|
|
11
|
-
* place: those edges aren't what the engine clips.
|
|
6
|
+
* showing where the panorama engine's source-crop is. The engine
|
|
7
|
+
* clips ALONG the pan axis:
|
|
12
8
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* User perceives this as TOP and BOTTOM of their landscape view.
|
|
17
|
-
* • portrait capture (horizontal pan): clip = sensor X (cols).
|
|
18
|
-
* User perceives this as LEFT and RIGHT of their portrait view.
|
|
9
|
+
* • Portrait capture (horizontal pan / Mode B):
|
|
10
|
+
* clip = sensor X (cols). User perceives this as LEFT and RIGHT
|
|
11
|
+
* of their portrait view.
|
|
19
12
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* • landscape device: user-top/bottom == JS-left/right (because
|
|
24
|
-
* the user's vertical maps to JS-horizontal
|
|
25
|
-
* under portrait-lock). Bars on JS-left/right.
|
|
13
|
+
* • Landscape capture (vertical pan / Mode A):
|
|
14
|
+
* clip = sensor Y (rows). User perceives this as TOP and BOTTOM
|
|
15
|
+
* of their landscape view.
|
|
26
16
|
*
|
|
27
|
-
*
|
|
28
|
-
* **No orientation detection needed in this component.** The
|
|
29
|
-
* engine has already arranged for the clip to manifest at the same
|
|
30
|
-
* JS edges regardless of physical device orientation.
|
|
17
|
+
* ## v0.12.0 update (R2-lite)
|
|
31
18
|
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
19
|
+
* Pre-v0.12 this component assumed the host app was orientation-
|
|
20
|
+
* locked to portrait, in which case ALL device orientations mapped
|
|
21
|
+
* to JS-left + JS-right for the bars (because the user's vertical
|
|
22
|
+
* mapped to JS-horizontal under portrait-lock). Under R2-lite the
|
|
23
|
+
* SDK no longer holds the UI in portrait, so JS coordinates align
|
|
24
|
+
* with the physical device orientation reported by
|
|
25
|
+
* `useDeviceOrientation()`. The bars now live at:
|
|
26
|
+
*
|
|
27
|
+
* portrait, portrait-upside-down → JS-left + JS-right (horizontal pan)
|
|
28
|
+
* landscape-left, landscape-right → JS-top + JS-bottom (vertical pan)
|
|
29
|
+
*
|
|
30
|
+
* Mounting: the flagship `<Camera>` component mounts this overlay
|
|
31
|
+
* by default in v0.12.0 (PR-3 wiring); Layer-2 hosts can mount it
|
|
32
|
+
* themselves via the public export.
|
|
33
|
+
*
|
|
34
|
+
* ## Bar dimensions
|
|
35
|
+
*
|
|
36
|
+
* Bar `(1 - panFraction) / 2` of the pan-axis extent. For the
|
|
37
|
+
* default engine constant `kPanAxisFractionRect = 0.70`, each bar
|
|
38
|
+
* is 15 % of the pan-axis extent — visibly substantial, matching
|
|
39
|
+
* what the engine clips out per frame.
|
|
36
40
|
*/
|
|
37
41
|
|
|
38
42
|
import React from 'react';
|
|
39
43
|
import { StyleSheet, View } from 'react-native';
|
|
40
44
|
|
|
45
|
+
import { useDeviceOrientation } from './useDeviceOrientation';
|
|
46
|
+
|
|
41
47
|
|
|
42
48
|
export interface ViewportCropOverlayProps {
|
|
43
49
|
/**
|
|
@@ -52,15 +58,31 @@ export interface ViewportCropOverlayProps {
|
|
|
52
58
|
export function ViewportCropOverlay({
|
|
53
59
|
panFraction,
|
|
54
60
|
}: ViewportCropOverlayProps): React.JSX.Element | null {
|
|
61
|
+
const orientation = useDeviceOrientation();
|
|
62
|
+
|
|
55
63
|
if (panFraction >= 1) return null;
|
|
56
64
|
|
|
57
|
-
// (1 - panFraction) / 2 of the
|
|
65
|
+
// (1 - panFraction) / 2 of the pan-axis extent on each side.
|
|
58
66
|
const barPercent = `${((1 - panFraction) / 2) * 100}%` as const;
|
|
59
67
|
|
|
68
|
+
const isLandscape =
|
|
69
|
+
orientation === 'landscape-left' || orientation === 'landscape-right';
|
|
70
|
+
|
|
60
71
|
return (
|
|
61
72
|
<View pointerEvents="none" style={styles.root}>
|
|
62
|
-
|
|
63
|
-
|
|
73
|
+
{isLandscape ? (
|
|
74
|
+
<>
|
|
75
|
+
{/* Vertical-pan capture: bars at JS-top + JS-bottom. */}
|
|
76
|
+
<View style={[styles.bar, { left: 0, right: 0, top: 0, height: barPercent }]} />
|
|
77
|
+
<View style={[styles.bar, { left: 0, right: 0, bottom: 0, height: barPercent }]} />
|
|
78
|
+
</>
|
|
79
|
+
) : (
|
|
80
|
+
<>
|
|
81
|
+
{/* Horizontal-pan capture: bars at JS-left + JS-right. */}
|
|
82
|
+
<View style={[styles.bar, { left: 0, top: 0, bottom: 0, width: barPercent }]} />
|
|
83
|
+
<View style={[styles.bar, { right: 0, top: 0, bottom: 0, width: barPercent }]} />
|
|
84
|
+
</>
|
|
85
|
+
)}
|
|
64
86
|
</View>
|
|
65
87
|
);
|
|
66
88
|
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Unit tests for `useOrientationDrift` — exercises the pure
|
|
4
|
+
* state-transition function `_computeDriftStateForTests` directly.
|
|
5
|
+
*
|
|
6
|
+
* Why not test the hook end-to-end via render: the lib's jest
|
|
7
|
+
* config is `preset: 'ts-jest'` + `testEnvironment: 'node'` — no
|
|
8
|
+
* React Native preset, no `@testing-library/react-native`. See the
|
|
9
|
+
* jest.config.js header comment: "If we ever add component-render
|
|
10
|
+
* tests we'd flip to the RN preset then." The component-render
|
|
11
|
+
* tests for `<OrientationDriftModal>`, `<PanoramaBandOverlay>`,
|
|
12
|
+
* `<ViewportCropOverlay>`, and `<Camera>` composition (all called
|
|
13
|
+
* out in the v0.12 plan) will all need that flip. Setting it up
|
|
14
|
+
* is grouped in Phase 5 of the plan (Tests) rather than scattered
|
|
15
|
+
* across each PR. For PR-1, the pure state-transition function
|
|
16
|
+
* carries the full behavioural contract — same approach
|
|
17
|
+
* `useThrottledFrameProcessor.test.ts` uses for its throttle gate.
|
|
18
|
+
*
|
|
19
|
+
* The 5 cases below cover the full state machine per the plan
|
|
20
|
+
* (lines 119, 277):
|
|
21
|
+
*
|
|
22
|
+
* (a) no change → not drifted
|
|
23
|
+
* (b) orientation changes during active=true → drifted
|
|
24
|
+
* (c) drift state survives further changes (latching)
|
|
25
|
+
* (d) inactive → captureOrientation undefined
|
|
26
|
+
* (e) active resets snapshot (false → true → false → true cycle)
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
// Mock `react-native-sensors` BEFORE importing the SUT. The hook
|
|
30
|
+
// itself transitively pulls in `useDeviceOrientation` which imports
|
|
31
|
+
// `accelerometer` from `react-native-sensors` — an ES module that
|
|
32
|
+
// jest can't parse without the RN preset (which jest.config.js
|
|
33
|
+
// intentionally avoids; see config header comment). We're only
|
|
34
|
+
// testing the pure transition function below, but TS imports are
|
|
35
|
+
// transitive so we still need to silence the chain.
|
|
36
|
+
jest.mock('react-native-sensors', () => ({
|
|
37
|
+
accelerometer: { subscribe: jest.fn(() => ({ unsubscribe: jest.fn() })) },
|
|
38
|
+
setUpdateIntervalForType: jest.fn(),
|
|
39
|
+
SensorTypes: { accelerometer: 'accelerometer' },
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
// eslint-disable-next-line import/first
|
|
43
|
+
import { _computeDriftStateForTests } from '../useOrientationDrift';
|
|
44
|
+
|
|
45
|
+
const INITIAL = { captureOrientation: undefined, drifted: false };
|
|
46
|
+
|
|
47
|
+
describe('_computeDriftStateForTests (useOrientationDrift core logic)', () => {
|
|
48
|
+
describe('(a) no change → not drifted', () => {
|
|
49
|
+
it('stays in initial state when active is false from the start', () => {
|
|
50
|
+
const next = _computeDriftStateForTests(INITIAL, false, 'portrait');
|
|
51
|
+
expect(next).toEqual({ captureOrientation: undefined, drifted: false });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('snapshots orientation when active flips true, drifted starts false', () => {
|
|
55
|
+
const next = _computeDriftStateForTests(INITIAL, true, 'portrait');
|
|
56
|
+
expect(next).toEqual({ captureOrientation: 'portrait', drifted: false });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('stays clean when active=true and orientation does not change', () => {
|
|
60
|
+
const after1 = _computeDriftStateForTests(INITIAL, true, 'portrait');
|
|
61
|
+
const after2 = _computeDriftStateForTests(after1, true, 'portrait');
|
|
62
|
+
const after3 = _computeDriftStateForTests(after2, true, 'portrait');
|
|
63
|
+
expect(after3).toEqual({ captureOrientation: 'portrait', drifted: false });
|
|
64
|
+
// Reference equality: once steady, returns the prev ref so
|
|
65
|
+
// React's setState becomes a no-op (no re-render).
|
|
66
|
+
expect(after2).toBe(after1);
|
|
67
|
+
expect(after3).toBe(after2);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('(b) orientation changes during active=true → drifted', () => {
|
|
72
|
+
it('latches drifted=true when orientation changes mid-active', () => {
|
|
73
|
+
const after1 = _computeDriftStateForTests(INITIAL, true, 'portrait');
|
|
74
|
+
const after2 = _computeDriftStateForTests(after1, true, 'landscape-left');
|
|
75
|
+
expect(after2).toEqual({ captureOrientation: 'portrait', drifted: true });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('captures the ORIGINAL orientation in captureOrientation, not the new one', () => {
|
|
79
|
+
const after1 = _computeDriftStateForTests(INITIAL, true, 'portrait');
|
|
80
|
+
const after2 = _computeDriftStateForTests(after1, true, 'landscape-right');
|
|
81
|
+
// captureOrientation MUST remain the snapshot (portrait), not
|
|
82
|
+
// the current rotation — that's how the drift modal copy
|
|
83
|
+
// ("captured in PORTRAIT, now LANDSCAPE-RIGHT") works.
|
|
84
|
+
expect(after2.captureOrientation).toBe('portrait');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('detects drift to any of the 3 other orientations', () => {
|
|
88
|
+
const cases: Array<['portrait', 'portrait-upside-down' | 'landscape-left' | 'landscape-right']> = [
|
|
89
|
+
['portrait', 'portrait-upside-down'],
|
|
90
|
+
['portrait', 'landscape-left'],
|
|
91
|
+
['portrait', 'landscape-right'],
|
|
92
|
+
];
|
|
93
|
+
for (const [captured, drifted] of cases) {
|
|
94
|
+
const after1 = _computeDriftStateForTests(INITIAL, true, captured);
|
|
95
|
+
const after2 = _computeDriftStateForTests(after1, true, drifted);
|
|
96
|
+
expect(after2.drifted).toBe(true);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('(c) drift state survives further changes (latching)', () => {
|
|
102
|
+
it('stays drifted even if the user rotates back to the captured orientation', () => {
|
|
103
|
+
// User rotates portrait → landscape (drift triggers) → portrait
|
|
104
|
+
// (back to original). The flag MUST stay latched. Rationale:
|
|
105
|
+
// the engine docstring says cross-mode capture is "best-effort,
|
|
106
|
+
// not supported" — a brief rotation pollutes the buffer even
|
|
107
|
+
// if the user rotates back, so the safe action is decisive
|
|
108
|
+
// abandonment regardless of post-detection orientation.
|
|
109
|
+
const after1 = _computeDriftStateForTests(INITIAL, true, 'portrait');
|
|
110
|
+
const after2 = _computeDriftStateForTests(after1, true, 'landscape-left');
|
|
111
|
+
const after3 = _computeDriftStateForTests(after2, true, 'portrait');
|
|
112
|
+
expect(after3).toEqual({ captureOrientation: 'portrait', drifted: true });
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('stays drifted across multiple subsequent orientation changes', () => {
|
|
116
|
+
const after1 = _computeDriftStateForTests(INITIAL, true, 'portrait');
|
|
117
|
+
const after2 = _computeDriftStateForTests(after1, true, 'landscape-left');
|
|
118
|
+
const after3 = _computeDriftStateForTests(after2, true, 'landscape-right');
|
|
119
|
+
const after4 = _computeDriftStateForTests(after3, true, 'portrait-upside-down');
|
|
120
|
+
expect(after4.drifted).toBe(true);
|
|
121
|
+
expect(after4.captureOrientation).toBe('portrait');
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe('(d) inactive → captureOrientation undefined', () => {
|
|
126
|
+
it('clears the snapshot when active flips back to false', () => {
|
|
127
|
+
const after1 = _computeDriftStateForTests(INITIAL, true, 'portrait');
|
|
128
|
+
const after2 = _computeDriftStateForTests(after1, false, 'portrait');
|
|
129
|
+
expect(after2).toEqual({ captureOrientation: undefined, drifted: false });
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('clears the drift flag when active flips back to false', () => {
|
|
133
|
+
const after1 = _computeDriftStateForTests(INITIAL, true, 'portrait');
|
|
134
|
+
const after2 = _computeDriftStateForTests(after1, true, 'landscape-left');
|
|
135
|
+
expect(after2.drifted).toBe(true);
|
|
136
|
+
const after3 = _computeDriftStateForTests(after2, false, 'landscape-left');
|
|
137
|
+
expect(after3).toEqual({ captureOrientation: undefined, drifted: false });
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('is idempotent — no state change when inactive and already clear', () => {
|
|
141
|
+
const after1 = _computeDriftStateForTests(INITIAL, false, 'portrait');
|
|
142
|
+
const after2 = _computeDriftStateForTests(after1, false, 'landscape-left');
|
|
143
|
+
// Same ref → setState becomes a no-op.
|
|
144
|
+
expect(after2).toBe(after1);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe('(e) active resets snapshot', () => {
|
|
149
|
+
it('re-snapshots on a fresh active cycle (false → true → false → true)', () => {
|
|
150
|
+
// Cycle 1: capture in portrait, drift.
|
|
151
|
+
const c1a = _computeDriftStateForTests(INITIAL, true, 'portrait');
|
|
152
|
+
const c1b = _computeDriftStateForTests(c1a, true, 'landscape-left');
|
|
153
|
+
expect(c1b).toEqual({ captureOrientation: 'portrait', drifted: true });
|
|
154
|
+
|
|
155
|
+
// Stop the capture.
|
|
156
|
+
const cleared = _computeDriftStateForTests(c1b, false, 'landscape-left');
|
|
157
|
+
expect(cleared).toEqual({ captureOrientation: undefined, drifted: false });
|
|
158
|
+
|
|
159
|
+
// Cycle 2: re-capture, now in landscape-left. Snapshot
|
|
160
|
+
// should be landscape-left, NOT carry over the old portrait.
|
|
161
|
+
const c2a = _computeDriftStateForTests(cleared, true, 'landscape-left');
|
|
162
|
+
expect(c2a).toEqual({ captureOrientation: 'landscape-left', drifted: false });
|
|
163
|
+
|
|
164
|
+
// And staying in landscape-left should not drift.
|
|
165
|
+
const c2b = _computeDriftStateForTests(c2a, true, 'landscape-left');
|
|
166
|
+
expect(c2b.drifted).toBe(false);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
});
|
|
@@ -2,15 +2,24 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* useDeviceOrientation — physical device orientation hook.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
5
|
+
* Hooks into the accelerometer to report the device's physical
|
|
6
|
+
* orientation as a 4-way `DeviceOrientation` value. Works
|
|
7
|
+
* identically regardless of host configuration:
|
|
8
|
+
*
|
|
9
|
+
* - Portrait-locked host (Info.plist UISupportedInterfaceOrientations
|
|
10
|
+
* restricted to Portrait): RN's `useWindowDimensions` returns
|
|
11
|
+
* portrait dims regardless of physical tilt. This hook reads
|
|
12
|
+
* the sensor directly, so text overlays (REC banner, pan-speed
|
|
13
|
+
* pill, live frame strip) can still follow the user's hold.
|
|
14
|
+
* - Non-locked host (Info.plist supports all 4): the OS rotates
|
|
15
|
+
* the framebuffer with the device; `useWindowDimensions` reflects
|
|
16
|
+
* the rotated JS layout. This hook still reports physical tilt
|
|
17
|
+
* — useful in combination with window dims to detect whether
|
|
18
|
+
* the screen rotated to match the device (`<Camera>`'s v0.12
|
|
19
|
+
* `homeIndicatorEdge` logic uses both signals together).
|
|
20
|
+
*
|
|
21
|
+
* Either way the sensor is the single source of truth for "where
|
|
22
|
+
* the user's hands actually are."
|
|
14
23
|
*
|
|
15
24
|
* 2026-05-21 (v0.2 — Expo modules removal) — rewritten back onto
|
|
16
25
|
* `react-native-sensors` accelerometer. `expo-sensors`'
|