react-native-image-stitcher 0.11.1 → 0.13.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 +151 -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 +191 -0
- package/dist/camera/Camera.js +250 -9
- 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 +639 -21
- 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
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* OrientationDriftModal — informational popup shown when the SDK
|
|
4
|
+
* auto-abandons an in-progress capture because the device rotated
|
|
5
|
+
* between Mode A (landscape + vertical pan) and Mode B (portrait
|
|
6
|
+
* + horizontal pan) mid-flight.
|
|
7
|
+
*
|
|
8
|
+
* ## When this modal appears
|
|
9
|
+
*
|
|
10
|
+
* In the v0.12 `<Camera>` integration, the modal is rendered while
|
|
11
|
+
* `useOrientationDrift(active).drifted === true`. By the time the
|
|
12
|
+
* modal renders, the capture has ALREADY been stopped (the
|
|
13
|
+
* `<Camera>` component's drift effect calls the engine's `stop()`
|
|
14
|
+
* the same render). The modal exists solely to explain to the
|
|
15
|
+
* user what happened — no "Continue" / "Resume" affordance because
|
|
16
|
+
* the engine docstring at `incremental.ts:373-403` is explicit
|
|
17
|
+
* that cross-mode capture is "best-effort, not supported" and
|
|
18
|
+
* continuing past drift produces malformed output.
|
|
19
|
+
*
|
|
20
|
+
* ## Layer-2 host usage
|
|
21
|
+
*
|
|
22
|
+
* Hosts using `CameraView` directly (rather than the flagship
|
|
23
|
+
* `<Camera>`) can compose this modal with `useOrientationDrift`
|
|
24
|
+
* for the same auto-abandon UX:
|
|
25
|
+
*
|
|
26
|
+
* const drift = useOrientationDrift(captureActive);
|
|
27
|
+
* useEffect(() => {
|
|
28
|
+
* if (drift.drifted) {
|
|
29
|
+
* // host abandons capture (engine stop + state cleanup)
|
|
30
|
+
* stopCapture();
|
|
31
|
+
* }
|
|
32
|
+
* }, [drift.drifted]);
|
|
33
|
+
*
|
|
34
|
+
* return <>
|
|
35
|
+
* <CameraView ... />
|
|
36
|
+
* <OrientationDriftModal
|
|
37
|
+
* visible={drift.drifted}
|
|
38
|
+
* captureOrientation={drift.captureOrientation}
|
|
39
|
+
* currentOrientation={drift.currentOrientation}
|
|
40
|
+
* onAcknowledge={dismissDriftModal}
|
|
41
|
+
* />
|
|
42
|
+
* </>;
|
|
43
|
+
*
|
|
44
|
+
* ## Accessibility
|
|
45
|
+
*
|
|
46
|
+
* Modal `role` defaults to RN's native dialog handling. The OK
|
|
47
|
+
* button carries an `accessibilityRole='button'` + label. Body
|
|
48
|
+
* text uses `accessibilityRole='text'` so the orientation summary
|
|
49
|
+
* is read by VoiceOver / TalkBack.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
import React from 'react';
|
|
53
|
+
import { Modal, Pressable, StyleSheet, Text, View } from 'react-native';
|
|
54
|
+
|
|
55
|
+
import { type DeviceOrientation } from './useDeviceOrientation';
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
export interface OrientationDriftModalProps {
|
|
59
|
+
/**
|
|
60
|
+
* Show / hide. In the `<Camera>` integration this is driven by
|
|
61
|
+
* the latched `drifted` flag from `useOrientationDrift`.
|
|
62
|
+
*/
|
|
63
|
+
visible: boolean;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Orientation the capture started in. Shown in the body copy
|
|
67
|
+
* ("Capture started in PORTRAIT") so the user understands the
|
|
68
|
+
* baseline. `undefined` is tolerated (the modal hides the line);
|
|
69
|
+
* the prop is optional only to mirror `useOrientationDrift`'s
|
|
70
|
+
* return shape (which has `undefined` when inactive). When the
|
|
71
|
+
* modal is `visible`, drift detection means this was non-
|
|
72
|
+
* undefined at the moment the flag latched — so undefined here
|
|
73
|
+
* is unlikely in practice.
|
|
74
|
+
*/
|
|
75
|
+
captureOrientation: DeviceOrientation | undefined;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Current device orientation. Shown in the body copy ("now
|
|
79
|
+
* LANDSCAPE-LEFT") so the user understands what changed.
|
|
80
|
+
*/
|
|
81
|
+
currentOrientation: DeviceOrientation;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Tapped when the user dismisses with OK. By the time the
|
|
85
|
+
* modal renders the capture is already stopped; this callback
|
|
86
|
+
* exists only to clear the latched drift state so the next
|
|
87
|
+
* capture can start fresh.
|
|
88
|
+
*/
|
|
89
|
+
onAcknowledge: () => void;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Pretty-print a `DeviceOrientation` for body copy. Returns the
|
|
95
|
+
* uppercase form because the modal copy reads as "Capture started
|
|
96
|
+
* in PORTRAIT, now LANDSCAPE-LEFT" — uppercase orientations stand
|
|
97
|
+
* out from the surrounding lowercase sentence.
|
|
98
|
+
*/
|
|
99
|
+
function formatOrientation(o: DeviceOrientation): string {
|
|
100
|
+
switch (o) {
|
|
101
|
+
case 'portrait':
|
|
102
|
+
return 'PORTRAIT';
|
|
103
|
+
case 'portrait-upside-down':
|
|
104
|
+
return 'PORTRAIT-UPSIDE-DOWN';
|
|
105
|
+
case 'landscape-left':
|
|
106
|
+
return 'LANDSCAPE-LEFT';
|
|
107
|
+
case 'landscape-right':
|
|
108
|
+
return 'LANDSCAPE-RIGHT';
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
export function OrientationDriftModal(
|
|
114
|
+
props: OrientationDriftModalProps,
|
|
115
|
+
): React.JSX.Element {
|
|
116
|
+
const { visible, captureOrientation, currentOrientation, onAcknowledge } = props;
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<Modal
|
|
120
|
+
visible={visible}
|
|
121
|
+
transparent
|
|
122
|
+
animationType="fade"
|
|
123
|
+
onRequestClose={onAcknowledge}
|
|
124
|
+
accessibilityLabel="Capture cancelled — orientation drift"
|
|
125
|
+
// v0.12.0 — see PanoramaSettingsModal for the same prop's
|
|
126
|
+
// rationale. Declaring all orientations prevents iOS from
|
|
127
|
+
// force-rotating the window to portrait when this modal opens
|
|
128
|
+
// mid-rotation, which would otherwise leave the underlying
|
|
129
|
+
// <Camera>'s ARSession in a stale-orientation state on dismiss.
|
|
130
|
+
supportedOrientations={[
|
|
131
|
+
'portrait',
|
|
132
|
+
'portrait-upside-down',
|
|
133
|
+
'landscape-left',
|
|
134
|
+
'landscape-right',
|
|
135
|
+
]}
|
|
136
|
+
>
|
|
137
|
+
<View style={styles.backdrop}>
|
|
138
|
+
<View style={styles.card}>
|
|
139
|
+
<Text style={styles.title} accessibilityRole="header">
|
|
140
|
+
Capture cancelled
|
|
141
|
+
</Text>
|
|
142
|
+
|
|
143
|
+
<Text style={styles.body} accessibilityRole="text">
|
|
144
|
+
Rotation detected mid-capture. Please hold the device
|
|
145
|
+
steady and try again.
|
|
146
|
+
</Text>
|
|
147
|
+
|
|
148
|
+
{captureOrientation !== undefined && (
|
|
149
|
+
<Text style={styles.subBody} accessibilityRole="text">
|
|
150
|
+
Capture started in {formatOrientation(captureOrientation)},
|
|
151
|
+
now {formatOrientation(currentOrientation)}.
|
|
152
|
+
</Text>
|
|
153
|
+
)}
|
|
154
|
+
|
|
155
|
+
<Pressable
|
|
156
|
+
style={({ pressed }) => [
|
|
157
|
+
styles.button,
|
|
158
|
+
pressed && styles.buttonPressed,
|
|
159
|
+
]}
|
|
160
|
+
onPress={onAcknowledge}
|
|
161
|
+
accessibilityRole="button"
|
|
162
|
+
accessibilityLabel="OK"
|
|
163
|
+
>
|
|
164
|
+
<Text style={styles.buttonLabel}>OK</Text>
|
|
165
|
+
</Pressable>
|
|
166
|
+
</View>
|
|
167
|
+
</View>
|
|
168
|
+
</Modal>
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
const styles = StyleSheet.create({
|
|
174
|
+
backdrop: {
|
|
175
|
+
flex: 1,
|
|
176
|
+
backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
|
177
|
+
alignItems: 'center',
|
|
178
|
+
justifyContent: 'center',
|
|
179
|
+
paddingHorizontal: 32,
|
|
180
|
+
},
|
|
181
|
+
card: {
|
|
182
|
+
backgroundColor: '#1c1c1e',
|
|
183
|
+
borderRadius: 14,
|
|
184
|
+
paddingHorizontal: 20,
|
|
185
|
+
paddingVertical: 24,
|
|
186
|
+
width: '100%',
|
|
187
|
+
maxWidth: 340,
|
|
188
|
+
},
|
|
189
|
+
title: {
|
|
190
|
+
color: '#fff',
|
|
191
|
+
fontSize: 18,
|
|
192
|
+
fontWeight: '600',
|
|
193
|
+
marginBottom: 12,
|
|
194
|
+
textAlign: 'center',
|
|
195
|
+
},
|
|
196
|
+
body: {
|
|
197
|
+
color: '#e5e5ea',
|
|
198
|
+
fontSize: 15,
|
|
199
|
+
lineHeight: 21,
|
|
200
|
+
textAlign: 'center',
|
|
201
|
+
marginBottom: 12,
|
|
202
|
+
},
|
|
203
|
+
subBody: {
|
|
204
|
+
color: '#8e8e93',
|
|
205
|
+
fontSize: 13,
|
|
206
|
+
lineHeight: 18,
|
|
207
|
+
textAlign: 'center',
|
|
208
|
+
marginBottom: 20,
|
|
209
|
+
},
|
|
210
|
+
button: {
|
|
211
|
+
backgroundColor: '#0a84ff',
|
|
212
|
+
borderRadius: 10,
|
|
213
|
+
paddingVertical: 12,
|
|
214
|
+
alignItems: 'center',
|
|
215
|
+
},
|
|
216
|
+
buttonPressed: {
|
|
217
|
+
backgroundColor: '#0860c0',
|
|
218
|
+
},
|
|
219
|
+
buttonLabel: {
|
|
220
|
+
color: '#fff',
|
|
221
|
+
fontSize: 17,
|
|
222
|
+
fontWeight: '600',
|
|
223
|
+
},
|
|
224
|
+
});
|
|
@@ -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
|
}
|