react-native-image-stitcher 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +181 -0
  2. package/README.md +33 -17
  3. package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +33 -5
  4. package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +73 -1
  5. package/dist/camera/Camera.d.ts +226 -0
  6. package/dist/camera/Camera.js +208 -20
  7. package/dist/camera/CameraView.d.ts +6 -0
  8. package/dist/camera/CameraView.js +2 -2
  9. package/dist/camera/CaptureHeader.js +39 -16
  10. package/dist/camera/CapturePreview.js +13 -1
  11. package/dist/camera/CaptureThumbnailStrip.d.ts +25 -1
  12. package/dist/camera/CaptureThumbnailStrip.js +17 -4
  13. package/dist/camera/PanoramaBandOverlay.d.ts +76 -0
  14. package/dist/camera/PanoramaBandOverlay.js +90 -33
  15. package/dist/camera/PanoramaConfirmModal.js +11 -1
  16. package/dist/camera/selectCaptureDevice.d.ts +93 -0
  17. package/dist/camera/selectCaptureDevice.js +131 -0
  18. package/dist/camera/useCapture.d.ts +40 -0
  19. package/dist/camera/useCapture.js +50 -12
  20. package/dist/camera/useContentRotation.d.ts +99 -0
  21. package/dist/camera/useContentRotation.js +124 -0
  22. package/dist/index.d.ts +1 -3
  23. package/dist/index.js +6 -5
  24. package/package.json +1 -1
  25. package/src/camera/Camera.tsx +546 -32
  26. package/src/camera/CameraView.tsx +9 -0
  27. package/src/camera/CaptureHeader.tsx +39 -16
  28. package/src/camera/CapturePreview.tsx +12 -0
  29. package/src/camera/CaptureThumbnailStrip.tsx +44 -4
  30. package/src/camera/PanoramaBandOverlay.tsx +97 -35
  31. package/src/camera/PanoramaConfirmModal.tsx +10 -0
  32. package/src/camera/__tests__/bandThumbRotation.test.ts +120 -0
  33. package/src/camera/__tests__/homeIndicatorEdge.test.ts +116 -0
  34. package/src/camera/__tests__/selectCaptureDevice.test.ts +177 -0
  35. package/src/camera/__tests__/useContentRotation.test.ts +89 -0
  36. package/src/camera/selectCaptureDevice.ts +187 -0
  37. package/src/camera/useCapture.ts +99 -11
  38. package/src/camera/useContentRotation.ts +149 -0
  39. package/src/index.ts +6 -2
@@ -0,0 +1,131 @@
1
+ "use strict";
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * selectCaptureDevice — capability-aware back-camera selection.
5
+ *
6
+ * Replaces the single-physical-device request that caused two
7
+ * user-visible bugs (see docs/plans/2026-06-01-v0.13.2-multilens-
8
+ * device-selection.md):
9
+ *
10
+ * 1. 0.5× silently showed the wide-angle FOV on phones where the
11
+ * ultra-wide is only exposed inside a multi-cam logical device —
12
+ * vision-camera's single-lens filter mis-scored and fell back to
13
+ * a plain wide-angle device.
14
+ * 2. flash threw `flash-not-available` on 0.5× because the standalone
15
+ * ultra-wide device has no torch unit.
16
+ *
17
+ * Both stem from mounting ONE standalone physical device per lens. The
18
+ * fix: prefer a MULTI-CAM device that carries the ultra-wide (so a
19
+ * single mounted device spans both FOVs via zoom AND carries the torch
20
+ * through its wide-angle member). Fall back to standalone devices for
21
+ * phones — common on Android — where the ultra-wide has no multi-cam
22
+ * grouping, so we don't regress those.
23
+ *
24
+ * Pure + synchronous: takes a plain device list (the structural subset
25
+ * of vision-camera's `CameraDevice` we need) and returns the choice.
26
+ * No React, no vision-camera hooks — unit-tested directly.
27
+ */
28
+ Object.defineProperty(exports, "__esModule", { value: true });
29
+ exports.selectCaptureDevice = selectCaptureDevice;
30
+ exports.zoomForLens = zoomForLens;
31
+ const hasLens = (d, lens) => d.physicalDevices.includes(lens);
32
+ /**
33
+ * Choose the back-camera device(s) for capture.
34
+ *
35
+ * Priority:
36
+ * 1. multicam — a multi-cam device containing BOTH wide + ultra-wide
37
+ * (best: one device, zoom-switch, torch via the wide member).
38
+ * 2. standalone-uw — a standalone wide AND a standalone ultra-wide
39
+ * exist as separate devices (device-swap on lens change; flash
40
+ * hidden on the torchless ultra-wide).
41
+ * 3. wide-only — no ultra-wide reachable; wide-angle only.
42
+ *
43
+ * @param devices All enumerated camera devices (any position).
44
+ */
45
+ function selectCaptureDevice(devices) {
46
+ const back = devices.filter((d) => d.position === 'back');
47
+ if (back.length === 0) {
48
+ return {
49
+ device: null,
50
+ ultraWideDevice: null,
51
+ mode: 'wide-only',
52
+ has0_5x: false,
53
+ hasTorch: false,
54
+ };
55
+ }
56
+ // ── 1. Prefer a multi-cam device that carries BOTH wide + ultra-wide.
57
+ // Among candidates, prefer the one that ALSO has a torch (so flash
58
+ // works on every lens), then the one spanning the widest zoom range
59
+ // (more lenses → more reach), as a stable tiebreak.
60
+ const multicamCandidates = back.filter((d) => d.isMultiCam &&
61
+ hasLens(d, 'wide-angle-camera') &&
62
+ hasLens(d, 'ultra-wide-angle-camera'));
63
+ if (multicamCandidates.length > 0) {
64
+ const device = multicamCandidates.reduce((best, d) => {
65
+ // torch-bearing wins; then wider zoom span; then more lenses.
66
+ if (d.hasTorch !== best.hasTorch)
67
+ return d.hasTorch ? d : best;
68
+ const span = d.maxZoom - d.minZoom;
69
+ const bestSpan = best.maxZoom - best.minZoom;
70
+ if (span !== bestSpan)
71
+ return span > bestSpan ? d : best;
72
+ return d.physicalDevices.length > best.physicalDevices.length ? d : best;
73
+ });
74
+ return {
75
+ device,
76
+ ultraWideDevice: null,
77
+ mode: 'multicam',
78
+ has0_5x: true,
79
+ hasTorch: device.hasTorch,
80
+ };
81
+ }
82
+ // ── 2. Standalone ultra-wide + standalone wide as separate devices.
83
+ // CRITICAL: this fallback is what keeps phones (esp. Android) where
84
+ // the ultra-wide has NO multi-cam grouping working — without it,
85
+ // restricting to multicam would REINTRODUCE the "0.5× shows wide" bug
86
+ // for that device population.
87
+ //
88
+ // Prefer a torch-bearing wide-angle device as the `1×`/primary mount.
89
+ const wideDevices = back.filter((d) => hasLens(d, 'wide-angle-camera'));
90
+ const ultraWide = back.find((d) => !d.isMultiCam && hasLens(d, 'ultra-wide-angle-camera')) ??
91
+ back.find((d) => hasLens(d, 'ultra-wide-angle-camera')) ??
92
+ null;
93
+ if (wideDevices.length > 0 && ultraWide != null) {
94
+ // Prefer the simplest wide device (fewest extra lenses) with a torch
95
+ // as the 1× mount, so 1× flash works. Falls back to any wide device.
96
+ const primary = wideDevices.find((d) => d.hasTorch) ?? wideDevices[0];
97
+ return {
98
+ device: primary,
99
+ ultraWideDevice: ultraWide,
100
+ mode: 'standalone-uw',
101
+ has0_5x: true,
102
+ hasTorch: primary.hasTorch,
103
+ };
104
+ }
105
+ // ── 3. Wide-angle only (no ultra-wide reachable on this device).
106
+ const wideOnly = wideDevices.find((d) => d.hasTorch) ?? wideDevices[0] ?? back[0];
107
+ return {
108
+ device: wideOnly,
109
+ ultraWideDevice: null,
110
+ mode: 'wide-only',
111
+ has0_5x: false,
112
+ hasTorch: wideOnly.hasTorch,
113
+ };
114
+ }
115
+ /**
116
+ * Map a UI lens label to a vision-camera `zoom` value for the
117
+ * `multicam` mode (where lens switching is zoom, not device swap).
118
+ *
119
+ * - `1×` → the device's `neutralZoom` (wide-angle baseline; vision-
120
+ * camera docs: "where the camera is in wide-angle mode and hasn't
121
+ * switched to ultra-wide or telephoto yet").
122
+ * - `0.5×` → `minZoom` (the ultra-wide end of the zoom range).
123
+ *
124
+ * Returns `neutralZoom` for any non-0.5× label as a safe default.
125
+ * Only meaningful in `multicam` mode; the standalone path swaps devices
126
+ * and ignores this.
127
+ */
128
+ function zoomForLens(device, lens) {
129
+ return lens === '0.5x' ? device.minZoom : device.neutralZoom;
130
+ }
131
+ //# sourceMappingURL=selectCaptureDevice.js.map
@@ -24,6 +24,7 @@
24
24
  * still use the SDK's quality + stitching modules.
25
25
  */
26
26
  import { Camera, useCameraDevice, type PhysicalCameraDeviceType, type TakePhotoOptions } from 'react-native-vision-camera';
27
+ import { type CaptureDeviceMode } from './selectCaptureDevice';
27
28
  import type { CaptureResult, QualityThresholds } from '../types';
28
29
  /**
29
30
  * Hook input. Everything optional; sensible defaults are applied
@@ -66,8 +67,22 @@ export interface UseCaptureOptions {
66
67
  * behaves as if `preferredPhysicalDevice` was undefined). The
67
68
  * returned `availablePhysicalDevices` exposes what the device
68
69
  * actually offers so the host can render an appropriate switcher.
70
+ *
71
+ * v0.13.2 — superseded by `lens` for `<Camera>`'s own use (see
72
+ * `selectCaptureDevice`). Still honoured for direct Layer-2 hosts.
69
73
  */
70
74
  preferredPhysicalDevice?: PhysicalCameraDeviceType;
75
+ /**
76
+ * v0.13.2 — the active UI lens (`1×` / `0.5×`). When supplied, the
77
+ * hook uses capability-aware selection (`selectCaptureDevice`): it
78
+ * prefers a multi-cam device spanning both FOVs (lens switched via
79
+ * `zoom`, torch available on every lens), and falls back to a
80
+ * standalone ultra-wide device-swap only where no such multi-cam
81
+ * device exists. Fixes the "0.5× shows wide-angle on some phones"
82
+ * and "flash unavailable on 0.5×" bugs. When omitted, the legacy
83
+ * `preferredPhysicalDevice` path is used (backwards-compatible).
84
+ */
85
+ lens?: '1x' | '0.5x';
71
86
  }
72
87
  /**
73
88
  * Per-call options for `takePhoto`. Separate from `UseCaptureOptions`
@@ -136,6 +151,31 @@ export interface UseCaptureReturn {
136
151
  * load). Always populated by the time the camera is mountable.
137
152
  */
138
153
  availablePhysicalDevices: PhysicalCameraDeviceType[];
154
+ /**
155
+ * v0.13.2 — how lenses are switched for the mounted device:
156
+ * 'multicam' — one device spans both FOVs; switch via `deviceZoom`.
157
+ * 'standalone-uw' — separate ultra-wide device; switch by remounting.
158
+ * 'wide-only' — no ultra-wide; no 0.5× chooser.
159
+ */
160
+ captureMode: CaptureDeviceMode;
161
+ /**
162
+ * v0.13.2 — whether the device can offer a 0.5× ultra-wide lens AT ALL
163
+ * (real capability, replacing the old hardcoded assumption). Drives
164
+ * whether `<Camera>` renders the lens chooser.
165
+ */
166
+ has0_5x: boolean;
167
+ /**
168
+ * v0.13.2 — whether the currently-MOUNTED device has a torch. Drives
169
+ * the flash control's availability (the standalone ultra-wide has none).
170
+ */
171
+ deviceHasTorch: boolean;
172
+ /**
173
+ * v0.13.2 — the `zoom` value to apply for the active lens in
174
+ * `multicam` mode (0.5× → ultra-wide end, 1× → wide baseline).
175
+ * `undefined` in standalone/wide-only modes (lens = device identity,
176
+ * no zoom needed). Pass to `<CameraView zoom>`.
177
+ */
178
+ deviceZoom: number | undefined;
139
179
  }
140
180
  export declare function useCapture(options?: UseCaptureOptions): UseCaptureReturn;
141
181
  //# sourceMappingURL=useCapture.d.ts.map
@@ -29,6 +29,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
29
29
  exports.useCapture = useCapture;
30
30
  const react_1 = require("react");
31
31
  const react_native_vision_camera_1 = require("react-native-vision-camera");
32
+ const selectCaptureDevice_1 = require("./selectCaptureDevice");
32
33
  const runQualityCheck_1 = require("../quality/runQualityCheck");
33
34
  const normaliseOrientation_1 = require("../quality/normaliseOrientation");
34
35
  const paths_1 = require("../utils/paths");
@@ -61,28 +62,61 @@ function makeCaptureResult(photo, qualityReport) {
61
62
  };
62
63
  }
63
64
  function useCapture(options = {}) {
64
- const { cameraPosition = 'back', enableQualityChecks = false, qualityThresholds, takePhotoOptions, preferredPhysicalDevice, } = options;
65
+ const { cameraPosition = 'back', enableQualityChecks = false, qualityThresholds, takePhotoOptions, preferredPhysicalDevice, lens, } = options;
65
66
  const cameraRef = (0, react_1.useRef)(null);
66
- // 2026-05-14 physical-lens-aware device picker.
67
+ const allDevices = (0, react_native_vision_camera_1.useCameraDevices)();
68
+ // v0.13.2 — capability-aware selection (`lens` supplied) vs legacy
69
+ // per-lens physical-device swap (`preferredPhysicalDevice`).
67
70
  //
68
- // When `preferredPhysicalDevice` is supplied, ask vision-camera
69
- // for a device that exposes that specific physical lens (e.g.,
70
- // 'ultra-wide-angle-camera'). Falls back to the position-default
71
- // when the device doesn't have that lens. When undefined, behaves
72
- // identically to the pre-2026-05-14 useCameraDevice(position) call.
73
- const deviceWithPreferred = (0, react_native_vision_camera_1.useCameraDevice)(cameraPosition, {
71
+ // Capability-aware: `selectCaptureDevice` prefers a multi-cam device
72
+ // that spans wide + ultra-wide (so 0.5× is reached via `zoom` and the
73
+ // torch works on every lens), falling back to a standalone ultra-wide
74
+ // device-swap only where the platform has no such multi-cam grouping.
75
+ // This fixes (a) 0.5× silently showing the wide-angle FOV on phones
76
+ // where the ultra-wide is only inside a multi-cam device, and (b)
77
+ // flash being unavailable on the torchless standalone ultra-wide.
78
+ const selection = (0, react_1.useMemo)(() => (0, selectCaptureDevice_1.selectCaptureDevice)(allDevices), [allDevices]);
79
+ // Legacy path (no `lens`): preserve the pre-v0.13.2 per-physical-lens
80
+ // request so direct Layer-2 hosts that pass `preferredPhysicalDevice`
81
+ // are unaffected.
82
+ const legacyDevice = (0, react_native_vision_camera_1.useCameraDevice)(cameraPosition, {
74
83
  physicalDevices: preferredPhysicalDevice ? [preferredPhysicalDevice] : undefined,
75
84
  });
76
- const deviceFallback = (0, react_native_vision_camera_1.useCameraDevice)(cameraPosition);
77
- const device = deviceWithPreferred ?? deviceFallback;
85
+ const legacyFallback = (0, react_native_vision_camera_1.useCameraDevice)(cameraPosition);
86
+ // The mounted device:
87
+ // - lens supplied + multicam → the single multi-cam device
88
+ // (lens switched via `zoom`, computed below).
89
+ // - lens supplied + standalone-uw→ swap to the ultra-wide device on
90
+ // 0.5×, else the wide primary (matches the legacy swap, but with
91
+ // correct device identity from `selectCaptureDevice`).
92
+ // - lens supplied + wide-only → the wide device (0.5× hidden).
93
+ // - no lens → legacy behaviour.
94
+ let device;
95
+ let activeZoom;
96
+ if (lens != null) {
97
+ if (selection.mode === 'standalone-uw' && lens === '0.5x') {
98
+ device = selection.ultraWideDevice ?? selection.device ?? legacyFallback;
99
+ }
100
+ else {
101
+ device = selection.device ?? legacyFallback;
102
+ }
103
+ activeZoom =
104
+ selection.mode === 'multicam' && selection.device
105
+ ? (0, selectCaptureDevice_1.zoomForLens)(selection.device, lens)
106
+ : undefined;
107
+ }
108
+ else {
109
+ device = legacyDevice ?? legacyFallback;
110
+ activeZoom = undefined;
111
+ }
78
112
  // Enumerate ALL physical lens types available on the chosen
79
113
  // position so the host can decide whether to render a switcher.
80
114
  // Vision-camera's `useCameraDevices()` returns CameraDevice[]; each
81
115
  // has `physicalDevices: PhysicalCameraDeviceType[]`. We dedupe the
82
116
  // union across all devices at `position` so the host sees the full
83
117
  // set the platform exposes (some phones expose ultra-wide only via
84
- // a separate logical camera, not the main one).
85
- const allDevices = (0, react_native_vision_camera_1.useCameraDevices)();
118
+ // a separate logical camera, not the main one). `allDevices` is
119
+ // computed once above (shared with `selectCaptureDevice`).
86
120
  const availablePhysicalDevices = (0, react_1.useMemo)(() => {
87
121
  const seen = new Set();
88
122
  for (const d of allDevices) {
@@ -176,6 +210,10 @@ function useCapture(options = {}) {
176
210
  isCapturing,
177
211
  takePhoto,
178
212
  availablePhysicalDevices,
213
+ captureMode: selection.mode,
214
+ has0_5x: selection.has0_5x,
215
+ deviceHasTorch: device?.hasTorch ?? false,
216
+ deviceZoom: activeZoom,
179
217
  };
180
218
  }
181
219
  //# sourceMappingURL=useCapture.js.map
@@ -0,0 +1,99 @@
1
+ /**
2
+ * useContentRotation — returns a CSS transform that rotates control
3
+ * content so labels stay upright relative to device gravity,
4
+ * regardless of whether the OS rotated the framebuffer.
5
+ *
6
+ * ## Why this exists
7
+ *
8
+ * v0.12 anchored `<Camera>`'s bottom controls to the home-indicator
9
+ * edge so they stay in thumb reach on phones in landscape on
10
+ * non-locked iOS hosts. The anchoring works because the OS rotates
11
+ * the framebuffer to match the device, so a JS-bottom view in
12
+ * landscape is the device's actual landscape-bottom edge.
13
+ *
14
+ * On locked-portrait hosts (the most common production
15
+ * configuration) the OS does NOT rotate the framebuffer when the
16
+ * device tilts to landscape. v0.12 still anchored controls to
17
+ * "JS-bottom" — which is now the device's side edge — so the
18
+ * shutter sits where the thumb expects, BUT the labels inside
19
+ * each control (`AR`, `1×`, `0.5×`, the lens chip pills, the gear)
20
+ * render at their JS-portrait baseline, so the user holding the
21
+ * device sideways reads them at 90°.
22
+ *
23
+ * This hook fixes that by applying a `transform: rotate(±90°)` to
24
+ * the control's *content* so it appears upright relative to actual
25
+ * gravity, while the control container itself stays in place.
26
+ *
27
+ * ## How the rotation is computed
28
+ *
29
+ * Two signals:
30
+ * - **Framebuffer rotation** — what rotation has the OS already
31
+ * applied to the JS layout? Read from
32
+ * `useWindowDimensions().width > height` — non-locked +
33
+ * device-landscape is the only case where the OS rotates,
34
+ * and that's exactly when `jsLandscape === true`.
35
+ * - **Device-physical rotation** — what rotation does the device
36
+ * have relative to gravity? Read from `useDeviceOrientation()`
37
+ * (accelerometer-derived).
38
+ *
39
+ * The content rotation we apply is the *difference* between
40
+ * device-physical and framebuffer rotation, so the net rotation
41
+ * (content × framebuffer) equals device-physical → labels are
42
+ * upright in the world.
43
+ *
44
+ * ## Truth table
45
+ *
46
+ * | Host config | Device | jsLandscape | Net rot |
47
+ * |--- |--- |--- |--- |
48
+ * | Locked-portrait | portrait | false | 0° |
49
+ * | Locked-portrait | landscape-left | false | 90° |
50
+ * | Locked-portrait | landscape-right | false | -90° |
51
+ * | Locked-portrait | upside-down | false | 180° |
52
+ * | Non-locked | portrait | false | 0° |
53
+ * | Non-locked | landscape-left | true | 0° |
54
+ * | Non-locked | landscape-right | true | 0° |
55
+ *
56
+ * The 0° case is the common one (locked-portrait + device-portrait
57
+ * OR non-locked + framebuffer-already-rotated); we return an empty
58
+ * style object so React skips the layout work.
59
+ *
60
+ * ## Caveats
61
+ *
62
+ * - Rotation transforms preserve hit-testing in RN 0.84 (verified
63
+ * on iOS + Android), but historical RN versions had bugs in this
64
+ * area. If support for older RN is added, retest pressables.
65
+ * - Containers whose sized layouts depend on un-rotated content
66
+ * (e.g. a 100px-wide pill containing text that's now rotated 90°)
67
+ * may overflow. Fixed-size pills (the lens chip, AR toggle,
68
+ * flash button) are fine; the header title's `flex: 1 + textAlign:
69
+ * center` may need tuning when rotated — see `CaptureHeader`'s
70
+ * own rotation handling.
71
+ */
72
+ import { type ViewStyle } from 'react-native';
73
+ import { type DeviceOrientation } from './useDeviceOrientation';
74
+ export type ContentRotationDeg = 0 | 90 | -90 | 180;
75
+ /**
76
+ * Return type for `useContentRotation`. Typed structurally on just
77
+ * the `transform` property so it spreads cleanly into ViewStyle,
78
+ * TextStyle, AND ImageStyle — all three accept identical transform
79
+ * shapes in RN 0.84. Returning the more specific `ViewStyle` would
80
+ * collide with ImageStyle's stricter `overflow` enum at <Image>
81
+ * call sites.
82
+ */
83
+ export type ContentRotationStyle = {
84
+ transform?: ViewStyle['transform'];
85
+ };
86
+ /**
87
+ * Pure rotation computation. Exported so tests can exercise the
88
+ * full truth table without booting a React render.
89
+ */
90
+ export declare function contentRotationDeg(jsLandscape: boolean, deviceOrient: DeviceOrientation): ContentRotationDeg;
91
+ /**
92
+ * Returns the rotation as a ready-to-spread style object. Empty
93
+ * object in the common 0° case so React skips the layout work.
94
+ * Type `ContentRotationStyle` is structurally just `{ transform? }`
95
+ * so call sites can spread it into ViewStyle, TextStyle, or
96
+ * ImageStyle interchangeably.
97
+ */
98
+ export declare function useContentRotation(): ContentRotationStyle;
99
+ //# sourceMappingURL=useContentRotation.d.ts.map
@@ -0,0 +1,124 @@
1
+ "use strict";
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * useContentRotation — returns a CSS transform that rotates control
5
+ * content so labels stay upright relative to device gravity,
6
+ * regardless of whether the OS rotated the framebuffer.
7
+ *
8
+ * ## Why this exists
9
+ *
10
+ * v0.12 anchored `<Camera>`'s bottom controls to the home-indicator
11
+ * edge so they stay in thumb reach on phones in landscape on
12
+ * non-locked iOS hosts. The anchoring works because the OS rotates
13
+ * the framebuffer to match the device, so a JS-bottom view in
14
+ * landscape is the device's actual landscape-bottom edge.
15
+ *
16
+ * On locked-portrait hosts (the most common production
17
+ * configuration) the OS does NOT rotate the framebuffer when the
18
+ * device tilts to landscape. v0.12 still anchored controls to
19
+ * "JS-bottom" — which is now the device's side edge — so the
20
+ * shutter sits where the thumb expects, BUT the labels inside
21
+ * each control (`AR`, `1×`, `0.5×`, the lens chip pills, the gear)
22
+ * render at their JS-portrait baseline, so the user holding the
23
+ * device sideways reads them at 90°.
24
+ *
25
+ * This hook fixes that by applying a `transform: rotate(±90°)` to
26
+ * the control's *content* so it appears upright relative to actual
27
+ * gravity, while the control container itself stays in place.
28
+ *
29
+ * ## How the rotation is computed
30
+ *
31
+ * Two signals:
32
+ * - **Framebuffer rotation** — what rotation has the OS already
33
+ * applied to the JS layout? Read from
34
+ * `useWindowDimensions().width > height` — non-locked +
35
+ * device-landscape is the only case where the OS rotates,
36
+ * and that's exactly when `jsLandscape === true`.
37
+ * - **Device-physical rotation** — what rotation does the device
38
+ * have relative to gravity? Read from `useDeviceOrientation()`
39
+ * (accelerometer-derived).
40
+ *
41
+ * The content rotation we apply is the *difference* between
42
+ * device-physical and framebuffer rotation, so the net rotation
43
+ * (content × framebuffer) equals device-physical → labels are
44
+ * upright in the world.
45
+ *
46
+ * ## Truth table
47
+ *
48
+ * | Host config | Device | jsLandscape | Net rot |
49
+ * |--- |--- |--- |--- |
50
+ * | Locked-portrait | portrait | false | 0° |
51
+ * | Locked-portrait | landscape-left | false | 90° |
52
+ * | Locked-portrait | landscape-right | false | -90° |
53
+ * | Locked-portrait | upside-down | false | 180° |
54
+ * | Non-locked | portrait | false | 0° |
55
+ * | Non-locked | landscape-left | true | 0° |
56
+ * | Non-locked | landscape-right | true | 0° |
57
+ *
58
+ * The 0° case is the common one (locked-portrait + device-portrait
59
+ * OR non-locked + framebuffer-already-rotated); we return an empty
60
+ * style object so React skips the layout work.
61
+ *
62
+ * ## Caveats
63
+ *
64
+ * - Rotation transforms preserve hit-testing in RN 0.84 (verified
65
+ * on iOS + Android), but historical RN versions had bugs in this
66
+ * area. If support for older RN is added, retest pressables.
67
+ * - Containers whose sized layouts depend on un-rotated content
68
+ * (e.g. a 100px-wide pill containing text that's now rotated 90°)
69
+ * may overflow. Fixed-size pills (the lens chip, AR toggle,
70
+ * flash button) are fine; the header title's `flex: 1 + textAlign:
71
+ * center` may need tuning when rotated — see `CaptureHeader`'s
72
+ * own rotation handling.
73
+ */
74
+ Object.defineProperty(exports, "__esModule", { value: true });
75
+ exports.contentRotationDeg = contentRotationDeg;
76
+ exports.useContentRotation = useContentRotation;
77
+ const react_native_1 = require("react-native");
78
+ const useDeviceOrientation_1 = require("./useDeviceOrientation");
79
+ /**
80
+ * Pure rotation computation. Exported so tests can exercise the
81
+ * full truth table without booting a React render.
82
+ */
83
+ function contentRotationDeg(jsLandscape, deviceOrient) {
84
+ // Framebuffer rotation relative to device-physical. Only the
85
+ // non-locked + device-landscape cases see a rotated framebuffer.
86
+ // jsLandscape can briefly be true mid-rotation on devices that
87
+ // aren't a clean landscape orientation; the device-orientation
88
+ // check below catches those and falls through to 0.
89
+ const fbRot = !jsLandscape ? 0
90
+ : deviceOrient === 'landscape-left' ? 90
91
+ : deviceOrient === 'landscape-right' ? -90
92
+ : 0;
93
+ // Device-physical rotation relative to gravity.
94
+ const deviceRot = deviceOrient === 'portrait' ? 0
95
+ : deviceOrient === 'landscape-left' ? 90
96
+ : deviceOrient === 'landscape-right' ? -90
97
+ : 180;
98
+ // Net rotation we need to apply to content so that
99
+ // content + framebuffer = device-physical (upright in the world).
100
+ // Normalise to [-180, 180] so transform values stay canonical.
101
+ let net = deviceRot - fbRot;
102
+ if (net > 180)
103
+ net -= 360;
104
+ if (net < -180)
105
+ net += 360;
106
+ return net;
107
+ }
108
+ /**
109
+ * Returns the rotation as a ready-to-spread style object. Empty
110
+ * object in the common 0° case so React skips the layout work.
111
+ * Type `ContentRotationStyle` is structurally just `{ transform? }`
112
+ * so call sites can spread it into ViewStyle, TextStyle, or
113
+ * ImageStyle interchangeably.
114
+ */
115
+ function useContentRotation() {
116
+ const orient = (0, useDeviceOrientation_1.useDeviceOrientation)();
117
+ const { width, height } = (0, react_native_1.useWindowDimensions)();
118
+ const jsLandscape = width > height;
119
+ const deg = contentRotationDeg(jsLandscape, orient);
120
+ return deg === 0
121
+ ? {}
122
+ : { transform: [{ rotate: `${deg}deg` }] };
123
+ }
124
+ //# sourceMappingURL=useContentRotation.js.map
package/dist/index.d.ts CHANGED
@@ -20,7 +20,7 @@
20
20
  * adds RetaiLens-specific features on top.
21
21
  */
22
22
  export { Camera, CameraError } from './camera/Camera';
23
- export type { CameraProps, CameraCaptureResult, CameraErrorCode, CaptureSource, CameraLens, StitchMode, Blender, SeamFinder, Warper, FramesDroppedInfo, } from './camera/Camera';
23
+ export type { CameraProps, CameraCaptureResult, CameraErrorCode, CaptureSource, CaptureSourcesMode, CameraLens, StitchMode, Blender, SeamFinder, Warper, FramesDroppedInfo, } from './camera/Camera';
24
24
  export { useARSession, ARTrackingState } from './ar/useARSession';
25
25
  export type { UseARSessionReturn, FramePose, } from './ar/useARSession';
26
26
  export { useIMUTranslationGate } from './sensors/useIMUTranslationGate';
@@ -47,9 +47,7 @@ export { CaptureStitchStatsToast, useStitchStatsToast, } from './camera/CaptureS
47
47
  export type { CaptureStitchStatsToastProps, UseStitchStatsToastReturn, } from './camera/CaptureStitchStatsToast';
48
48
  export { CaptureThumbnailStrip } from './camera/CaptureThumbnailStrip';
49
49
  export type { CaptureThumbnailItem } from './camera/CaptureThumbnailStrip';
50
- export { IncrementalPanGuide } from './camera/IncrementalPanGuide';
51
50
  export { PanoramaBandOverlay } from './camera/PanoramaBandOverlay';
52
- export { PanoramaGuidance } from './camera/PanoramaGuidance';
53
51
  export { PanoramaSettingsModal } from './camera/PanoramaSettingsModal';
54
52
  export type { PanoramaSettingsModalProps } from './camera/PanoramaSettingsModal';
55
53
  export { DEFAULT_PANORAMA_SETTINGS, DEFAULT_FLOW_GATE_SETTINGS, DEFAULT_SLITSCAN_SETTINGS, DEFAULT_HYBRID_SETTINGS, } from './camera/PanoramaSettings';
package/dist/index.js CHANGED
@@ -22,7 +22,7 @@
22
22
  * adds RetaiLens-specific features on top.
23
23
  */
24
24
  Object.defineProperty(exports, "__esModule", { value: true });
25
- exports.stitchVideo = exports.useStitcherWorklet = exports.useFrameProcessorDriver = exports.useFrameStream = exports.useThrottledFrameProcessor = exports.useFrameProcessor = exports.useKeyframeStream = exports.useIncrementalStitcher = exports.cleanupOldKeyframes = exports.getIncrementalNativeModule = exports.subscribeIncrementalState = exports.incrementalStitcherIsAvailable = exports.IncrementalOutcome = exports.OrientationDriftModal = exports.useOrientationDrift = exports.useDeviceOrientation = exports.useVideoCapture = exports.useCapture = exports.ViewportCropOverlay = exports.hybridSettingsToNativeConfig = exports.slitscanSettingsToNativeConfig = exports.panoramaSettingsToNativeConfig = exports.DEFAULT_HYBRID_SETTINGS = exports.DEFAULT_SLITSCAN_SETTINGS = exports.DEFAULT_FLOW_GATE_SETTINGS = exports.DEFAULT_PANORAMA_SETTINGS = exports.PanoramaSettingsModal = exports.PanoramaGuidance = exports.PanoramaBandOverlay = exports.IncrementalPanGuide = exports.CaptureThumbnailStrip = exports.useStitchStatsToast = exports.CaptureStitchStatsToast = exports.CaptureOrientationPill = exports.CaptureKeyframePill = exports.CaptureMemoryPill = exports.CaptureDebugOverlay = exports.CaptureStatusOverlay = exports.CapturePreview = exports.CaptureControlsBar = exports.CaptureHeader = exports.CameraView = exports.ARCameraView = exports.useIMUTranslationGate = exports.ARTrackingState = exports.useARSession = exports.CameraError = exports.Camera = void 0;
25
+ exports.stitchVideo = exports.useStitcherWorklet = exports.useFrameProcessorDriver = exports.useFrameStream = exports.useThrottledFrameProcessor = exports.useFrameProcessor = exports.useKeyframeStream = exports.useIncrementalStitcher = exports.cleanupOldKeyframes = exports.getIncrementalNativeModule = exports.subscribeIncrementalState = exports.incrementalStitcherIsAvailable = exports.IncrementalOutcome = exports.OrientationDriftModal = exports.useOrientationDrift = exports.useDeviceOrientation = exports.useVideoCapture = exports.useCapture = exports.ViewportCropOverlay = exports.hybridSettingsToNativeConfig = exports.slitscanSettingsToNativeConfig = exports.panoramaSettingsToNativeConfig = exports.DEFAULT_HYBRID_SETTINGS = exports.DEFAULT_SLITSCAN_SETTINGS = exports.DEFAULT_FLOW_GATE_SETTINGS = exports.DEFAULT_PANORAMA_SETTINGS = exports.PanoramaSettingsModal = exports.PanoramaBandOverlay = exports.CaptureThumbnailStrip = exports.useStitchStatsToast = exports.CaptureStitchStatsToast = exports.CaptureOrientationPill = exports.CaptureKeyframePill = exports.CaptureMemoryPill = exports.CaptureDebugOverlay = exports.CaptureStatusOverlay = exports.CapturePreview = exports.CaptureControlsBar = exports.CaptureHeader = exports.CameraView = exports.ARCameraView = exports.useIMUTranslationGate = exports.ARTrackingState = exports.useARSession = exports.CameraError = exports.Camera = void 0;
26
26
  // ─────────────────────────────────────────────────────────────────────
27
27
  // Layer 1 — the high-level <Camera> component
28
28
  // ─────────────────────────────────────────────────────────────────────
@@ -85,12 +85,13 @@ Object.defineProperty(exports, "CaptureStitchStatsToast", { enumerable: true, ge
85
85
  Object.defineProperty(exports, "useStitchStatsToast", { enumerable: true, get: function () { return CaptureStitchStatsToast_1.useStitchStatsToast; } });
86
86
  var CaptureThumbnailStrip_1 = require("./camera/CaptureThumbnailStrip");
87
87
  Object.defineProperty(exports, "CaptureThumbnailStrip", { enumerable: true, get: function () { return CaptureThumbnailStrip_1.CaptureThumbnailStrip; } });
88
- var IncrementalPanGuide_1 = require("./camera/IncrementalPanGuide");
89
- Object.defineProperty(exports, "IncrementalPanGuide", { enumerable: true, get: function () { return IncrementalPanGuide_1.IncrementalPanGuide; } });
88
+ // v0.13.1 IncrementalPanGuide (drift marker) and PanoramaGuidance
89
+ // (pan-speed pill) are no longer part of the public API. They remain
90
+ // in the tree as internal-only components but are not exported and not
91
+ // rendered by <Camera> (the `panGuide` / `panoramaGuidance` props were
92
+ // removed). Re-introduce here if a host need resurfaces.
90
93
  var PanoramaBandOverlay_1 = require("./camera/PanoramaBandOverlay");
91
94
  Object.defineProperty(exports, "PanoramaBandOverlay", { enumerable: true, get: function () { return PanoramaBandOverlay_1.PanoramaBandOverlay; } });
92
- var PanoramaGuidance_1 = require("./camera/PanoramaGuidance");
93
- Object.defineProperty(exports, "PanoramaGuidance", { enumerable: true, get: function () { return PanoramaGuidance_1.PanoramaGuidance; } });
94
95
  // Settings modal — the modal is in `PanoramaSettingsModal.tsx`, but
95
96
  // the type tree + defaults + JS↔native bridge live in dedicated
96
97
  // files since v0.4 (F10). The modal is now a thin presentational
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-image-stitcher",
3
- "version": "0.12.0",
3
+ "version": "0.14.0",
4
4
  "description": "Pose-aware panorama capture + stitching for React Native. One <Camera> component, both tap-to-photo and hold-to-pan modes, both AR-backed and IMU-fallback capture paths.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",