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
|
@@ -70,6 +70,18 @@ import type { IncrementalState } from '../stitching/incremental';
|
|
|
70
70
|
*/
|
|
71
71
|
export type BandCaptureOrientation = 'portrait' | 'portrait-upside-down' | 'landscape-left' | 'landscape-right';
|
|
72
72
|
export interface PanoramaBandOverlayProps {
|
|
73
|
+
/**
|
|
74
|
+
* v0.12.0 — `true` when the band should render as a vertical
|
|
75
|
+
* column in JS (anchor edge is JS-left or JS-right, i.e.
|
|
76
|
+
* non-locked host with device-landscape). `false` (default)
|
|
77
|
+
* renders the legacy horizontal strip — covers portrait-locked
|
|
78
|
+
* hosts in any device orientation AND non-locked hosts in
|
|
79
|
+
* portrait. The flagship `<Camera>` derives this from
|
|
80
|
+
* `useWindowDimensions()` + `useDeviceOrientation()` (see
|
|
81
|
+
* `homeIndicatorEdge` in `Camera.tsx`); Layer-2 hosts pass it
|
|
82
|
+
* directly.
|
|
83
|
+
*/
|
|
84
|
+
vertical?: boolean;
|
|
73
85
|
/**
|
|
74
86
|
* Latest engine state. Pass `useIncrementalStitcher().state`.
|
|
75
87
|
* Used for single-thumb fallback URI and fill-ratio when no
|
|
@@ -103,5 +115,5 @@ export interface PanoramaBandOverlayProps {
|
|
|
103
115
|
*/
|
|
104
116
|
captureOrientation?: BandCaptureOrientation;
|
|
105
117
|
}
|
|
106
|
-
export declare function PanoramaBandOverlay({ state, frameUris, captureOrientation, }: PanoramaBandOverlayProps): React.JSX.Element | null;
|
|
118
|
+
export declare function PanoramaBandOverlay({ state, frameUris, captureOrientation, vertical, }: PanoramaBandOverlayProps): React.JSX.Element | null;
|
|
107
119
|
//# sourceMappingURL=PanoramaBandOverlay.d.ts.map
|
|
@@ -141,26 +141,50 @@ const MULTI_THUMB_HARD_CAP = 32; // safety net; host typically caps at 24
|
|
|
141
141
|
* reads as user-right-arrow (pointing along the horizontal pan
|
|
142
142
|
* direction).
|
|
143
143
|
*/
|
|
144
|
-
function layoutFor(orientation) {
|
|
144
|
+
function layoutFor(orientation, vertical) {
|
|
145
145
|
const commonInner = {
|
|
146
146
|
alignItems: 'center',
|
|
147
147
|
paddingHorizontal: BAND_PADDING,
|
|
148
148
|
paddingVertical: BAND_PADDING,
|
|
149
149
|
backgroundColor: 'rgba(0, 0, 0, 0.55)',
|
|
150
150
|
};
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
// above the shutter row. The SDK's orientation lock holds the UI
|
|
155
|
-
// in portrait regardless of physical device rotation, so the band
|
|
156
|
-
// is ALWAYS a horizontal strip in JS coordinates. In landscape
|
|
157
|
-
// (physically held), the rendered strip visually appears as a
|
|
158
|
-
// vertical column on the viewport-side of the shutter.
|
|
151
|
+
// v0.12.0 — band structural orientation tracks the host's
|
|
152
|
+
// `vertical` flag (which the host derives from JS layout
|
|
153
|
+
// orientation):
|
|
159
154
|
//
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
//
|
|
155
|
+
// vertical=false Horizontal strip in JS coords. Under
|
|
156
|
+
// portrait-lock + device-landscape this appears
|
|
157
|
+
// as a vertical column on user-right via the
|
|
158
|
+
// un-rotated framebuffer.
|
|
159
|
+
// vertical=true Vertical column in JS coords. Non-locked
|
|
160
|
+
// + device-landscape — band lives along the
|
|
161
|
+
// JS-side strip where the home indicator is.
|
|
162
|
+
//
|
|
163
|
+
// What still varies by physical orientation regardless: the
|
|
164
|
+
// thumbnail flow direction so newest sits at the user-perceived
|
|
165
|
+
// pan-leading edge (flexDirection + arrowGlyph).
|
|
166
|
+
if (vertical) {
|
|
167
|
+
// Vertical band in JS coords (non-locked landscape). The OS
|
|
168
|
+
// rotated the framebuffer so user-top = JS-top, user-bottom =
|
|
169
|
+
// JS-bottom — same scroll direction regardless of whether the
|
|
170
|
+
// device is landscape-left or landscape-right. Latest grows
|
|
171
|
+
// toward user-bottom (= JS-bottom). flexDirection 'column'
|
|
172
|
+
// puts array[0]/oldest at JS-top.
|
|
173
|
+
return {
|
|
174
|
+
kind: 'landscape',
|
|
175
|
+
band: {
|
|
176
|
+
marginHorizontal: 8,
|
|
177
|
+
marginVertical: 16,
|
|
178
|
+
width: BAND_THICKNESS,
|
|
179
|
+
flexDirection: 'column',
|
|
180
|
+
...commonInner,
|
|
181
|
+
},
|
|
182
|
+
flexDirection: 'column',
|
|
183
|
+
arrowGlyph: '↓',
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
// vertical=false branch: pre-v0.12 horizontal-strip behavior
|
|
187
|
+
// keyed on device-physical orientation for thumbnail direction.
|
|
164
188
|
if (orientation === 'landscape-left') {
|
|
165
189
|
// Phone rotated 90° CCW from portrait (home indicator on the
|
|
166
190
|
// user's RIGHT). With UI orientation-locked to portrait:
|
|
@@ -221,7 +245,7 @@ function layoutFor(orientation) {
|
|
|
221
245
|
arrowGlyph: '→',
|
|
222
246
|
};
|
|
223
247
|
}
|
|
224
|
-
function PanoramaBandOverlay({ state, frameUris, captureOrientation, }) {
|
|
248
|
+
function PanoramaBandOverlay({ state, frameUris, captureOrientation, vertical = false, }) {
|
|
225
249
|
// 2026-05-18 (Issue #3 fix) — orientation source priority:
|
|
226
250
|
// 1. `captureOrientation` prop from the host (4-way; correct
|
|
227
251
|
// for landscape-left vs landscape-right disambiguation).
|
|
@@ -231,7 +255,7 @@ function PanoramaBandOverlay({ state, frameUris, captureOrientation, }) {
|
|
|
231
255
|
// sensibly before any orientation info is available).
|
|
232
256
|
const resolvedOrientation = captureOrientation
|
|
233
257
|
?? (state?.isLandscape ? 'landscape-left' : 'portrait');
|
|
234
|
-
const layout = (0, react_1.useMemo)(() => layoutFor(resolvedOrientation), [resolvedOrientation]);
|
|
258
|
+
const layout = (0, react_1.useMemo)(() => layoutFor(resolvedOrientation, vertical), [resolvedOrientation, vertical]);
|
|
235
259
|
const scrollRef = (0, react_1.useRef)(null);
|
|
236
260
|
// Trim incoming URIs to a hard cap. The host already caps at 24
|
|
237
261
|
// (AuditCaptureScreen) but defence-in-depth keeps the ScrollView
|
|
@@ -245,26 +269,22 @@ function PanoramaBandOverlay({ state, frameUris, captureOrientation, }) {
|
|
|
245
269
|
: frameUris;
|
|
246
270
|
}, [frameUris]);
|
|
247
271
|
const hasMultiThumb = cappedFrameUris.length > 0;
|
|
248
|
-
// Auto-scroll on content-size change.
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
// `row-reverse` (landscape-left) the latest is at JS-leftmost →
|
|
254
|
-
// scrollTo({x: 0}) shows it. The earlier always-scrollToEnd
|
|
255
|
-
// behaviour scrolled to OLDEST in row-reverse, which hid the
|
|
256
|
-
// just-captured frame off-screen at user-bottom.
|
|
272
|
+
// Auto-scroll on content-size change. `*-reverse` puts latest at
|
|
273
|
+
// scroll origin (scrollTo {0,0}); normal `row`/`column` puts
|
|
274
|
+
// latest at scroll end (scrollToEnd).
|
|
275
|
+
const isReverse = layout.flexDirection === 'row-reverse' ||
|
|
276
|
+
layout.flexDirection === 'column-reverse';
|
|
257
277
|
const onContentSizeChange = (0, react_1.useCallback)(() => {
|
|
258
278
|
const sv = scrollRef.current;
|
|
259
279
|
if (!sv)
|
|
260
280
|
return;
|
|
261
|
-
if (
|
|
281
|
+
if (isReverse) {
|
|
262
282
|
sv.scrollTo({ x: 0, y: 0, animated: false });
|
|
263
283
|
}
|
|
264
284
|
else {
|
|
265
285
|
sv.scrollToEnd({ animated: false });
|
|
266
286
|
}
|
|
267
|
-
}, [
|
|
287
|
+
}, [isReverse]);
|
|
268
288
|
// ── Single cumulative thumbnail (live-engine fallback) ──────────
|
|
269
289
|
//
|
|
270
290
|
// Same fill-ratio math as V12.14.9. Kept so live-stitching engines
|
|
@@ -285,25 +305,63 @@ function PanoramaBandOverlay({ state, frameUris, captureOrientation, }) {
|
|
|
285
305
|
const singleThumbPanLen = (0, react_1.useMemo)(() => {
|
|
286
306
|
return Math.max(SINGLE_THUMB_INNER, SINGLE_THUMB_MAX_PAN_LEN * fillRatio);
|
|
287
307
|
}, [fillRatio]);
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
//
|
|
291
|
-
//
|
|
292
|
-
//
|
|
308
|
+
// Image rotation transform for thumbnails. Captured frames are in
|
|
309
|
+
// user-perspective orientation (the capture pipeline rotates the
|
|
310
|
+
// sensor-native bytes via `outputOrientation="device"` + EXIF
|
|
311
|
+
// baking in `normaliseOrientation`). The thumbnail BOX is in
|
|
312
|
+
// JS coords. When JS coords are device-aligned (portrait-lock,
|
|
313
|
+
// i.e. vertical=false here) and the device is in landscape, the
|
|
314
|
+
// image content is rotated 90° from the box's axes → appears
|
|
315
|
+
// sideways without compensation. Apply a counter-rotation to
|
|
316
|
+
// line content up with the box's perceived "top".
|
|
293
317
|
//
|
|
294
|
-
//
|
|
295
|
-
//
|
|
296
|
-
//
|
|
297
|
-
//
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
318
|
+
// When vertical=true (non-locked + device-landscape; JS coords
|
|
319
|
+
// rotated with screen), the box IS user-aligned already. No
|
|
320
|
+
// rotation needed — the image is already correctly oriented for
|
|
321
|
+
// direct display.
|
|
322
|
+
//
|
|
323
|
+
// V12.14.9 → v0.12.0 — extended from single-thumb (cumulative
|
|
324
|
+
// panorama image fallback) to the multi-thumb path too. Pre-
|
|
325
|
+
// v0.12 the multi-thumb keyframe thumbnails had no rotation
|
|
326
|
+
// transform, so they appeared sideways in portrait-locked
|
|
327
|
+
// landscape captures (the case the example app's batch-keyframe
|
|
328
|
+
// engine hits).
|
|
329
|
+
const thumbRotationTransform = (0, react_1.useMemo)(() => {
|
|
330
|
+
// Empirical observation (on-device test 2026-05-28): captured
|
|
331
|
+
// per-keyframe JPEGs ARE saved in sensor-native landscape (not
|
|
332
|
+
// user-perspective), despite the cumulative panorama getting
|
|
333
|
+
// device-orientation rotation via finalize(). So:
|
|
334
|
+
//
|
|
335
|
+
// jsPortrait box + landscape device: box is device-aligned;
|
|
336
|
+
// image's "up" is at file-right (sensor convention). Rotate
|
|
337
|
+
// 90° CW (landscape-left) / 90° CCW (landscape-right) to
|
|
338
|
+
// align image up with box up.
|
|
339
|
+
// jsLandscape box + landscape device: box is user-aligned via
|
|
340
|
+
// OS screen rotation; image's "up" still at file-right. To
|
|
341
|
+
// align image up with box up, rotate the OPPOSITE direction
|
|
342
|
+
// from the jsPortrait case — the screen-rotation already
|
|
343
|
+
// handles half the work; we just need to compensate for the
|
|
344
|
+
// remaining mismatch.
|
|
345
|
+
if (vertical) {
|
|
346
|
+
if (resolvedOrientation === 'landscape-left')
|
|
347
|
+
return [{ rotate: '-90deg' }];
|
|
348
|
+
if (resolvedOrientation === 'landscape-right')
|
|
349
|
+
return [{ rotate: '90deg' }];
|
|
350
|
+
return undefined;
|
|
304
351
|
}
|
|
305
|
-
|
|
306
|
-
|
|
352
|
+
if (resolvedOrientation === 'landscape-left')
|
|
353
|
+
return [{ rotate: '90deg' }];
|
|
354
|
+
if (resolvedOrientation === 'landscape-right')
|
|
355
|
+
return [{ rotate: '-90deg' }];
|
|
356
|
+
return undefined;
|
|
357
|
+
}, [resolvedOrientation, vertical]);
|
|
358
|
+
const singleImageStyle = (0, react_1.useMemo)(() => thumbRotationTransform
|
|
359
|
+
? [react_native_1.StyleSheet.absoluteFill, { transform: thumbRotationTransform }]
|
|
360
|
+
: react_native_1.StyleSheet.absoluteFill, [thumbRotationTransform]);
|
|
361
|
+
// Same rotation applied to the per-keyframe (multi-thumb) tiles.
|
|
362
|
+
const multiThumbStyle = (0, react_1.useMemo)(() => thumbRotationTransform
|
|
363
|
+
? [styles.multiThumb, { transform: thumbRotationTransform }]
|
|
364
|
+
: styles.multiThumb, [thumbRotationTransform]);
|
|
307
365
|
return (react_1.default.createElement(react_native_1.View, { pointerEvents: "none", style: [styles.bandBase, layout.band] }, hasMultiThumb ? (
|
|
308
366
|
// Multi-thumb path: one image per accepted keyframe, scrolling
|
|
309
367
|
// horizontally (in JS-coords) within the band. Content
|
|
@@ -316,7 +374,10 @@ function PanoramaBandOverlay({ state, frameUris, captureOrientation, }) {
|
|
|
316
374
|
// adjacent to the newest thumbnail. Previously it was a
|
|
317
375
|
// sibling of the ScrollView at the band's far end, which
|
|
318
376
|
// looked detached when there were only a few thumbnails.
|
|
319
|
-
react_1.default.createElement(react_native_1.ScrollView, { ref: scrollRef,
|
|
377
|
+
react_1.default.createElement(react_native_1.ScrollView, { ref: scrollRef,
|
|
378
|
+
// Horizontal scroll in JS-portrait bands; vertical scroll
|
|
379
|
+
// in JS-landscape (non-locked host) bands.
|
|
380
|
+
horizontal: layout.kind === 'portrait', showsHorizontalScrollIndicator: false, showsVerticalScrollIndicator: false, style: styles.thumbScroll, contentContainerStyle: [
|
|
320
381
|
styles.thumbScrollContent,
|
|
321
382
|
{ flexDirection: layout.flexDirection },
|
|
322
383
|
], onContentSizeChange: onContentSizeChange },
|
|
@@ -328,7 +389,7 @@ function PanoramaBandOverlay({ state, frameUris, captureOrientation, }) {
|
|
|
328
389
|
// Composite key: idx prevents collisions if the same path
|
|
329
390
|
// ever gets re-emitted (shouldn't happen but cheap to be
|
|
330
391
|
// defensive). URI segment helps RN's image cache key.
|
|
331
|
-
key: `${idx}-${uri}`, source: { uri }, style:
|
|
392
|
+
key: `${idx}-${uri}`, source: { uri }, style: multiThumbStyle, resizeMode: "cover", fadeDuration: 0 }))),
|
|
332
393
|
react_1.default.createElement(react_native_1.View, { style: styles.arrowTrack },
|
|
333
394
|
react_1.default.createElement(react_native_1.Text, { style: styles.arrowGlyph }, layout.arrowGlyph)))) : (react_1.default.createElement(react_1.default.Fragment, null,
|
|
334
395
|
react_1.default.createElement(react_native_1.View, { style: [
|
|
@@ -145,7 +145,21 @@ function PanoramaSettingsModal({ visible, settings, onChange, onClose, }) {
|
|
|
145
145
|
// Flow-tunables section. Mirrors the type-level optionality of
|
|
146
146
|
// `frameSelection.flow`.
|
|
147
147
|
const showFlowTunables = settings.frameSelection.mode === 'flow-based';
|
|
148
|
-
return (react_1.default.createElement(react_native_1.Modal, { visible: visible, animationType: "slide", transparent: true, statusBarTranslucent: true, onRequestClose: onClose
|
|
148
|
+
return (react_1.default.createElement(react_native_1.Modal, { visible: visible, animationType: "slide", transparent: true, statusBarTranslucent: true, onRequestClose: onClose,
|
|
149
|
+
// v0.12.0 — RN's iOS Modal defaults to portrait-only. When a
|
|
150
|
+
// host removes its UIInterfaceOrientations portrait lock to
|
|
151
|
+
// support landscape capture, opening this modal while in
|
|
152
|
+
// landscape would force iOS to rotate the window scene to
|
|
153
|
+
// portrait, then the underlying <Camera>'s ARSession can end
|
|
154
|
+
// up with stale display-transform state on dismiss (preview
|
|
155
|
+
// renders sideways). Declaring all orientations keeps the
|
|
156
|
+
// window aligned with the device throughout the modal cycle.
|
|
157
|
+
supportedOrientations: [
|
|
158
|
+
'portrait',
|
|
159
|
+
'portrait-upside-down',
|
|
160
|
+
'landscape-left',
|
|
161
|
+
'landscape-right',
|
|
162
|
+
] },
|
|
149
163
|
react_1.default.createElement(react_native_1.View, { style: styles.backdrop },
|
|
150
164
|
react_1.default.createElement(react_native_1.View, { style: styles.sheet },
|
|
151
165
|
react_1.default.createElement(react_native_1.View, { style: styles.header },
|
|
@@ -1,37 +1,41 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* ViewportCropOverlay — V12.12.
|
|
2
|
+
* ViewportCropOverlay — V12.12 + v0.12.0 orientation-aware (R2-lite).
|
|
3
3
|
*
|
|
4
4
|
* Translucent dim bars on the camera preview's PAN-AXIS edges
|
|
5
|
-
* showing where the panorama engine's source-crop is.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
5
|
+
* showing where the panorama engine's source-crop is. The engine
|
|
6
|
+
* clips ALONG the pan axis:
|
|
7
|
+
*
|
|
8
|
+
* • Portrait capture (horizontal pan / Mode B):
|
|
9
|
+
* clip = sensor X (cols). User perceives this as LEFT and RIGHT
|
|
10
|
+
* of their portrait view.
|
|
11
|
+
*
|
|
12
|
+
* • Landscape capture (vertical pan / Mode A):
|
|
13
|
+
* clip = sensor Y (rows). User perceives this as TOP and BOTTOM
|
|
14
|
+
* of their landscape view.
|
|
15
|
+
*
|
|
16
|
+
* ## v0.12.0 update (R2-lite)
|
|
17
|
+
*
|
|
18
|
+
* Pre-v0.12 this component assumed the host app was orientation-
|
|
19
|
+
* locked to portrait, in which case ALL device orientations mapped
|
|
20
|
+
* to JS-left + JS-right for the bars (because the user's vertical
|
|
21
|
+
* mapped to JS-horizontal under portrait-lock). Under R2-lite the
|
|
22
|
+
* SDK no longer holds the UI in portrait, so JS coordinates align
|
|
23
|
+
* with the physical device orientation reported by
|
|
24
|
+
* `useDeviceOrientation()`. The bars now live at:
|
|
25
|
+
*
|
|
26
|
+
* portrait, portrait-upside-down → JS-left + JS-right (horizontal pan)
|
|
27
|
+
* landscape-left, landscape-right → JS-top + JS-bottom (vertical pan)
|
|
28
|
+
*
|
|
29
|
+
* Mounting: the flagship `<Camera>` component mounts this overlay
|
|
30
|
+
* by default in v0.12.0 (PR-3 wiring); Layer-2 hosts can mount it
|
|
31
|
+
* themselves via the public export.
|
|
32
|
+
*
|
|
33
|
+
* ## Bar dimensions
|
|
34
|
+
*
|
|
35
|
+
* Bar `(1 - panFraction) / 2` of the pan-axis extent. For the
|
|
36
|
+
* default engine constant `kPanAxisFractionRect = 0.70`, each bar
|
|
37
|
+
* is 15 % of the pan-axis extent — visibly substantial, matching
|
|
38
|
+
* what the engine clips out per frame.
|
|
35
39
|
*/
|
|
36
40
|
import React from 'react';
|
|
37
41
|
export interface ViewportCropOverlayProps {
|
|
@@ -1,39 +1,43 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
// SPDX-License-Identifier: Apache-2.0
|
|
3
3
|
/**
|
|
4
|
-
* ViewportCropOverlay — V12.12.
|
|
4
|
+
* ViewportCropOverlay — V12.12 + v0.12.0 orientation-aware (R2-lite).
|
|
5
5
|
*
|
|
6
6
|
* Translucent dim bars on the camera preview's PAN-AXIS edges
|
|
7
|
-
* showing where the panorama engine's source-crop is.
|
|
8
|
-
*
|
|
9
|
-
* the engine clipped the long sensor axis (perpendicular to pan
|
|
10
|
-
* in landscape, along pan in portrait) — that produced visible
|
|
11
|
-
* bars on the user-LEFT/RIGHT in landscape, which is the WRONG
|
|
12
|
-
* place: those edges aren't what the engine clips.
|
|
7
|
+
* showing where the panorama engine's source-crop is. The engine
|
|
8
|
+
* clips ALONG the pan axis:
|
|
13
9
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* User perceives this as TOP and BOTTOM of their landscape view.
|
|
18
|
-
* • portrait capture (horizontal pan): clip = sensor X (cols).
|
|
19
|
-
* User perceives this as LEFT and RIGHT of their portrait view.
|
|
10
|
+
* • Portrait capture (horizontal pan / Mode B):
|
|
11
|
+
* clip = sensor X (cols). User perceives this as LEFT and RIGHT
|
|
12
|
+
* of their portrait view.
|
|
20
13
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* • landscape device: user-top/bottom == JS-left/right (because
|
|
25
|
-
* the user's vertical maps to JS-horizontal
|
|
26
|
-
* under portrait-lock). Bars on JS-left/right.
|
|
14
|
+
* • Landscape capture (vertical pan / Mode A):
|
|
15
|
+
* clip = sensor Y (rows). User perceives this as TOP and BOTTOM
|
|
16
|
+
* of their landscape view.
|
|
27
17
|
*
|
|
28
|
-
*
|
|
29
|
-
* **No orientation detection needed in this component.** The
|
|
30
|
-
* engine has already arranged for the clip to manifest at the same
|
|
31
|
-
* JS edges regardless of physical device orientation.
|
|
18
|
+
* ## v0.12.0 update (R2-lite)
|
|
32
19
|
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
20
|
+
* Pre-v0.12 this component assumed the host app was orientation-
|
|
21
|
+
* locked to portrait, in which case ALL device orientations mapped
|
|
22
|
+
* to JS-left + JS-right for the bars (because the user's vertical
|
|
23
|
+
* mapped to JS-horizontal under portrait-lock). Under R2-lite the
|
|
24
|
+
* SDK no longer holds the UI in portrait, so JS coordinates align
|
|
25
|
+
* with the physical device orientation reported by
|
|
26
|
+
* `useDeviceOrientation()`. The bars now live at:
|
|
27
|
+
*
|
|
28
|
+
* portrait, portrait-upside-down → JS-left + JS-right (horizontal pan)
|
|
29
|
+
* landscape-left, landscape-right → JS-top + JS-bottom (vertical pan)
|
|
30
|
+
*
|
|
31
|
+
* Mounting: the flagship `<Camera>` component mounts this overlay
|
|
32
|
+
* by default in v0.12.0 (PR-3 wiring); Layer-2 hosts can mount it
|
|
33
|
+
* themselves via the public export.
|
|
34
|
+
*
|
|
35
|
+
* ## Bar dimensions
|
|
36
|
+
*
|
|
37
|
+
* Bar `(1 - panFraction) / 2` of the pan-axis extent. For the
|
|
38
|
+
* default engine constant `kPanAxisFractionRect = 0.70`, each bar
|
|
39
|
+
* is 15 % of the pan-axis extent — visibly substantial, matching
|
|
40
|
+
* what the engine clips out per frame.
|
|
37
41
|
*/
|
|
38
42
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
39
43
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
@@ -42,14 +46,19 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
42
46
|
exports.ViewportCropOverlay = ViewportCropOverlay;
|
|
43
47
|
const react_1 = __importDefault(require("react"));
|
|
44
48
|
const react_native_1 = require("react-native");
|
|
49
|
+
const useDeviceOrientation_1 = require("./useDeviceOrientation");
|
|
45
50
|
function ViewportCropOverlay({ panFraction, }) {
|
|
51
|
+
const orientation = (0, useDeviceOrientation_1.useDeviceOrientation)();
|
|
46
52
|
if (panFraction >= 1)
|
|
47
53
|
return null;
|
|
48
|
-
// (1 - panFraction) / 2 of the
|
|
54
|
+
// (1 - panFraction) / 2 of the pan-axis extent on each side.
|
|
49
55
|
const barPercent = `${((1 - panFraction) / 2) * 100}%`;
|
|
50
|
-
|
|
56
|
+
const isLandscape = orientation === 'landscape-left' || orientation === 'landscape-right';
|
|
57
|
+
return (react_1.default.createElement(react_native_1.View, { pointerEvents: "none", style: styles.root }, isLandscape ? (react_1.default.createElement(react_1.default.Fragment, null,
|
|
58
|
+
react_1.default.createElement(react_native_1.View, { style: [styles.bar, { left: 0, right: 0, top: 0, height: barPercent }] }),
|
|
59
|
+
react_1.default.createElement(react_native_1.View, { style: [styles.bar, { left: 0, right: 0, bottom: 0, height: barPercent }] }))) : (react_1.default.createElement(react_1.default.Fragment, null,
|
|
51
60
|
react_1.default.createElement(react_native_1.View, { style: [styles.bar, { left: 0, top: 0, bottom: 0, width: barPercent }] }),
|
|
52
|
-
react_1.default.createElement(react_native_1.View, { style: [styles.bar, { right: 0, top: 0, bottom: 0, width: barPercent }] })));
|
|
61
|
+
react_1.default.createElement(react_native_1.View, { style: [styles.bar, { right: 0, top: 0, bottom: 0, width: barPercent }] })))));
|
|
53
62
|
}
|
|
54
63
|
const styles = react_native_1.StyleSheet.create({
|
|
55
64
|
root: {
|
|
@@ -1,15 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* useDeviceOrientation — physical device orientation hook.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
4
|
+
* Hooks into the accelerometer to report the device's physical
|
|
5
|
+
* orientation as a 4-way `DeviceOrientation` value. Works
|
|
6
|
+
* identically regardless of host configuration:
|
|
7
|
+
*
|
|
8
|
+
* - Portrait-locked host (Info.plist UISupportedInterfaceOrientations
|
|
9
|
+
* restricted to Portrait): RN's `useWindowDimensions` returns
|
|
10
|
+
* portrait dims regardless of physical tilt. This hook reads
|
|
11
|
+
* the sensor directly, so text overlays (REC banner, pan-speed
|
|
12
|
+
* pill, live frame strip) can still follow the user's hold.
|
|
13
|
+
* - Non-locked host (Info.plist supports all 4): the OS rotates
|
|
14
|
+
* the framebuffer with the device; `useWindowDimensions` reflects
|
|
15
|
+
* the rotated JS layout. This hook still reports physical tilt
|
|
16
|
+
* — useful in combination with window dims to detect whether
|
|
17
|
+
* the screen rotated to match the device (`<Camera>`'s v0.12
|
|
18
|
+
* `homeIndicatorEdge` logic uses both signals together).
|
|
19
|
+
*
|
|
20
|
+
* Either way the sensor is the single source of truth for "where
|
|
21
|
+
* the user's hands actually are."
|
|
13
22
|
*
|
|
14
23
|
* 2026-05-21 (v0.2 — Expo modules removal) — rewritten back onto
|
|
15
24
|
* `react-native-sensors` accelerometer. `expo-sensors`'
|
|
@@ -3,15 +3,24 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* useDeviceOrientation — physical device orientation hook.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
6
|
+
* Hooks into the accelerometer to report the device's physical
|
|
7
|
+
* orientation as a 4-way `DeviceOrientation` value. Works
|
|
8
|
+
* identically regardless of host configuration:
|
|
9
|
+
*
|
|
10
|
+
* - Portrait-locked host (Info.plist UISupportedInterfaceOrientations
|
|
11
|
+
* restricted to Portrait): RN's `useWindowDimensions` returns
|
|
12
|
+
* portrait dims regardless of physical tilt. This hook reads
|
|
13
|
+
* the sensor directly, so text overlays (REC banner, pan-speed
|
|
14
|
+
* pill, live frame strip) can still follow the user's hold.
|
|
15
|
+
* - Non-locked host (Info.plist supports all 4): the OS rotates
|
|
16
|
+
* the framebuffer with the device; `useWindowDimensions` reflects
|
|
17
|
+
* the rotated JS layout. This hook still reports physical tilt
|
|
18
|
+
* — useful in combination with window dims to detect whether
|
|
19
|
+
* the screen rotated to match the device (`<Camera>`'s v0.12
|
|
20
|
+
* `homeIndicatorEdge` logic uses both signals together).
|
|
21
|
+
*
|
|
22
|
+
* Either way the sensor is the single source of truth for "where
|
|
23
|
+
* the user's hands actually are."
|
|
15
24
|
*
|
|
16
25
|
* 2026-05-21 (v0.2 — Expo modules removal) — rewritten back onto
|
|
17
26
|
* `react-native-sensors` accelerometer. `expo-sensors`'
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useOrientationDrift — detects mid-capture device rotation.
|
|
3
|
+
*
|
|
4
|
+
* Pairs with `useDeviceOrientation()` to surface the case where the
|
|
5
|
+
* user rotates the device *during* an active capture. The
|
|
6
|
+
* incremental stitching engine supports both portrait (Mode B,
|
|
7
|
+
* horizontal pan) and landscape (Mode A, vertical pan) capture
|
|
8
|
+
* modes as first-class — but mixing them mid-capture produces
|
|
9
|
+
* malformed output ("cross-mode capture is best-effort," per
|
|
10
|
+
* `incremental.ts:373-403`). Hosts that want to protect against
|
|
11
|
+
* this use this hook + `OrientationDriftModal` together: the
|
|
12
|
+
* `<Camera>` flagship component auto-abandons capture the instant
|
|
13
|
+
* `drifted === true` (PR-2 wiring); the modal surfaces an
|
|
14
|
+
* explanatory popup to the user.
|
|
15
|
+
*
|
|
16
|
+
* ## API contract
|
|
17
|
+
*
|
|
18
|
+
* Pass `active` true while a capture is in flight, false otherwise.
|
|
19
|
+
* Returns:
|
|
20
|
+
*
|
|
21
|
+
* - `captureOrientation` — the orientation snapshotted at the
|
|
22
|
+
* moment `active` transitioned false → true. `undefined` when
|
|
23
|
+
* `active` is false.
|
|
24
|
+
* - `currentOrientation` — live orientation from
|
|
25
|
+
* `useDeviceOrientation()`. Always defined (defaults to
|
|
26
|
+
* `'portrait'` until the accelerometer's first sample).
|
|
27
|
+
* - `drifted` — `true` IFF `active` is currently true AND
|
|
28
|
+
* `currentOrientation !== captureOrientation` at some point
|
|
29
|
+
* since the snapshot. **Latching** — once true, stays true
|
|
30
|
+
* until `active` flips back to false. This is intentional:
|
|
31
|
+
* after detection, callers should auto-abandon the capture
|
|
32
|
+
* (engine `stop()`); allowing the flag to clear before then
|
|
33
|
+
* would mask the drift if the user rotated back to the
|
|
34
|
+
* original orientation between the detection tick and the
|
|
35
|
+
* callers' abandonment effect.
|
|
36
|
+
*
|
|
37
|
+
* ## Semantics by transition
|
|
38
|
+
*
|
|
39
|
+
* - `active` false → true: snapshot `currentOrientation`;
|
|
40
|
+
* reset `drifted` to false.
|
|
41
|
+
* - `active` true (steady): if `currentOrientation !==
|
|
42
|
+
* captureOrientation` at any point, latch `drifted = true`.
|
|
43
|
+
* - `active` true → false: clear snapshot; reset `drifted`.
|
|
44
|
+
*
|
|
45
|
+
* ## Why a separate hook (rather than inlining in `<Camera>`)
|
|
46
|
+
*
|
|
47
|
+
* Hosts using the Layer-2 building blocks (`CameraView` directly,
|
|
48
|
+
* custom capture UX) can reuse this hook without mounting the
|
|
49
|
+
* full `<Camera>` flagship. Same composition pattern as
|
|
50
|
+
* `useIMUTranslationGate` and `useKeyframeStream`.
|
|
51
|
+
*
|
|
52
|
+
* ## Testing
|
|
53
|
+
*
|
|
54
|
+
* The pure state-transition function `_computeDriftStateForTests`
|
|
55
|
+
* is exported separately so jest can exercise all 5 transition
|
|
56
|
+
* cases without booting a React render. The hook itself is a
|
|
57
|
+
* thin wrapper around it (verified via on-device manual flow in
|
|
58
|
+
* the v0.12 verification checklist).
|
|
59
|
+
*/
|
|
60
|
+
import { type DeviceOrientation } from './useDeviceOrientation';
|
|
61
|
+
export interface UseOrientationDriftReturn {
|
|
62
|
+
/**
|
|
63
|
+
* `true` IFF a capture is active and the device has rotated since
|
|
64
|
+
* the snapshot taken at capture start. Latching: once true, stays
|
|
65
|
+
* true until `active` flips false.
|
|
66
|
+
*/
|
|
67
|
+
drifted: boolean;
|
|
68
|
+
/**
|
|
69
|
+
* Snapshot of `currentOrientation` at the moment `active`
|
|
70
|
+
* transitioned false → true. `undefined` when `active` is false.
|
|
71
|
+
*/
|
|
72
|
+
captureOrientation: DeviceOrientation | undefined;
|
|
73
|
+
/**
|
|
74
|
+
* Live device orientation from `useDeviceOrientation()`. Always
|
|
75
|
+
* defined. Exposed so callers (e.g. the drift modal) can show
|
|
76
|
+
* "captured in PORTRAIT, now LANDSCAPE-LEFT" copy without
|
|
77
|
+
* mounting `useDeviceOrientation()` themselves.
|
|
78
|
+
*/
|
|
79
|
+
currentOrientation: DeviceOrientation;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Internal state of the drift detector. Two scalar pieces: the
|
|
83
|
+
* snapshotted capture orientation (undefined when inactive) + the
|
|
84
|
+
* latched drift flag.
|
|
85
|
+
*/
|
|
86
|
+
interface DriftState {
|
|
87
|
+
captureOrientation: DeviceOrientation | undefined;
|
|
88
|
+
drifted: boolean;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Pure state-transition function for the drift detector. Exported
|
|
92
|
+
* with a `_` prefix to signal "internal — not part of the public
|
|
93
|
+
* API." Jest uses this directly so tests don't need a React
|
|
94
|
+
* renderer (the lib's jest config is pure-data / no RN preset).
|
|
95
|
+
*
|
|
96
|
+
* Given the previous state + the current `active` flag + the
|
|
97
|
+
* current device orientation, returns the new state. Idempotent
|
|
98
|
+
* when nothing changed (returns the same object reference) so
|
|
99
|
+
* downstream `useState(setState)` calls become no-ops.
|
|
100
|
+
*/
|
|
101
|
+
export declare function _computeDriftStateForTests(prev: DriftState, active: boolean, currentOrientation: DeviceOrientation): DriftState;
|
|
102
|
+
export declare function useOrientationDrift(active: boolean): UseOrientationDriftReturn;
|
|
103
|
+
export {};
|
|
104
|
+
//# sourceMappingURL=useOrientationDrift.d.ts.map
|