react-native-image-stitcher 0.13.0 → 0.14.1

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 +115 -0
  2. package/README.md +238 -62
  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 +71 -16
  6. package/dist/camera/Camera.js +167 -51
  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 +281 -118
  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
package/CHANGELOG.md CHANGED
@@ -16,6 +16,121 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
16
16
 
17
17
  ## [Unreleased]
18
18
 
19
+ ## [0.14.1] — 2026-06-01
20
+
21
+ ### Docs
22
+
23
+ - Refresh the npm README for the v0.14 API: full `<Camera>` prop
24
+ reference (incl. `captureSources`), a complete capture-screen sample,
25
+ the portrait recommendation, and a 0.13.x → 0.14 migration note. (The
26
+ 0.14.0 tarball shipped before this refresh landed; no code change.)
27
+ - Add a Docusaurus docs site (published to GitHub Pages).
28
+
29
+ ## [0.14.0] — 2026-06-01
30
+
31
+ ### Fixed — Android AR single-photo orientation (landscape was sideways)
32
+
33
+ Android AR `takePhoto` baked the wrong rotation into landscape captures
34
+ under a portrait-locked host: it derived the EXIF orientation from the
35
+ window display rotation (`WindowManager.defaultDisplay.rotation`), which
36
+ stays `ROTATION_0` when the activity is portrait-locked regardless of how
37
+ the device is physically held — so a landscape photo got a portrait EXIF
38
+ tag and came out 90° CW. The JS layer already passed the gyro device
39
+ orientation to `RNSARSession.takePhoto` (since v0.12), and iOS consumed
40
+ it, but the Android native side dropped it. Now Android threads the
41
+ device orientation through `takePhoto → requestTakePhoto → encodeToJpeg`,
42
+ mapping it to the correct `Surface.ROTATION_*` / EXIF tag. iOS unchanged
43
+ (already correct). Verified on-device (Samsung A35) in both landscape
44
+ orientations.
45
+
46
+ ### Added — `captureSources` constraint prop
47
+
48
+ `<Camera>` gains `captureSources?: 'ar' | 'non-ar' | 'both'` (default
49
+ `'both'`) — a constraint on which capture sources the host allows, layered
50
+ over `defaultCaptureSource` (which picks the initial source within it):
51
+
52
+ - `'both'` — AR + non-AR; the runtime AR toggle is shown (unchanged
53
+ default behaviour).
54
+ - `'ar'` — AR only; the AR toggle is hidden (nothing to switch to) and
55
+ the 0.5×/1× lens chooser is hidden (ARKit/ARCore can't use the
56
+ ultra-wide), keeping capture on the AR-capable 1× lens.
57
+ - `'non-ar'`— non-AR only; the AR toggle is hidden, the lens chooser stays.
58
+
59
+ A single-source constraint overrides a conflicting `defaultCaptureSource`.
60
+ Exported type: `CaptureSourcesMode`. Verified on-device (A35) across all
61
+ three modes.
62
+
63
+ ### Fixed — capability-aware lens selection (ultra-wide + flash on 0.5×)
64
+
65
+ `<Camera>` now selects the back camera device by real capability instead
66
+ of requesting a single physical lens per zoom level. `selectCaptureDevice`:
67
+
68
+ - **Prefers a multi-cam device** that spans wide + ultra-wide (lens
69
+ switched via `zoom`; torch available on every lens). On devices that
70
+ expose such a device (e.g. iPhone 16 Pro — verified `multicam`), this
71
+ fixes the user-reported "0.5× shows the wide-angle FOV" bug AND makes
72
+ flash work on 0.5× (the mounted multi-cam device carries the torch).
73
+ - **Falls back to a standalone ultra-wide** device-swap where no multi-cam
74
+ device exists (e.g. Samsung A35 — verified `standalone-uw`; vision-camera
75
+ surfaces the physical cameras separately there). 0.5× still shows the
76
+ ultra-wide FOV; flash hides because that standalone device is torchless.
77
+
78
+ `has0_5x` is now derived from the real device inventory (was hardcoded
79
+ `true`), so the lens chooser hides on wide-only hardware. 13 unit tests
80
+ cover the selection matrix incl. both edge cases (ultra-wide only in a
81
+ multi-cam group; ultra-wide only standalone).
82
+
83
+ Verified on-device: iPhone 16 Pro (multicam — 0.5× FOV + flash both work)
84
+ and Samsung A35 (standalone-uw — 0.5× FOV works, flash correctly hidden).
85
+
86
+ ### Added — Android portrait lock (SDK-enforced)
87
+
88
+ `<Camera>` now locks its host Activity to portrait on Android while
89
+ mounted, via `Activity.setRequestedOrientation`, **regardless of the
90
+ host app's `AndroidManifest` `screenOrientation`**. A landscape or
91
+ unlocked host still gets a portrait camera screen. The Activity's
92
+ prior orientation is captured on mount and restored on unmount.
93
+ Implemented in the native `RNSARSession` module (`lockPortrait()` /
94
+ `unlockOrientation()`) and driven from a `<Camera>` mount effect, so
95
+ it covers both the AR (ARCore) and non-AR (vision-camera) paths.
96
+ There is no opt-out — Android capture is portrait-only by design.
97
+
98
+ iOS is intentionally unchanged: supported orientations remain owned by
99
+ the host `Info.plist`. **Portrait is the recommended configuration on
100
+ both platforms; landscape is supported on iOS** for hosts that need it.
101
+
102
+ ### Fixed — landscape preview + thumbnail orientation (non-locked iOS)
103
+
104
+ - **Preview squish / sideways** under a non-locked host was caused by
105
+ an in-development `patch-package` patch to vision-camera's
106
+ `OrientationManager` (both `.kt` and `.swift`) that derived the
107
+ PREVIEW orientation from the accelerometer instead of the interface
108
+ orientation. In a portrait host held landscape this forced a
109
+ landscape preview into a portrait surface. The patch was removed and
110
+ vision-camera restored to pristine on both platforms.
111
+ - **Band keyframe thumbnails rotated 90°**: the per-keyframe tiles in
112
+ `PanoramaBandOverlay` were double-rotated — the saved `keyframe-N.jpg`
113
+ is sensor-native landscape + EXIF Orientation 6, which `<Image>`
114
+ already auto-rotates, so the extra JS transform was redundant in the
115
+ portrait-locked (`vertical=false`) path. The transform is now applied
116
+ only in the `vertical=true` (non-locked landscape) path.
117
+ - **Stitched-preview / confirm modals stuck portrait**: `CapturePreview`
118
+ and `PanoramaConfirmModal` were missing `supportedOrientations`
119
+ (RN's iOS `<Modal>` defaults to portrait-only). Both now declare all
120
+ four, matching `OrientationDriftModal` + `PanoramaSettingsModal`.
121
+ - **Idle thumbnail strip horizontal in landscape**: `CaptureThumbnailStrip`
122
+ gained a `vertical` prop (wired from the same `isSideEdge` signal as
123
+ the band) so the idle strip stacks vertically along the home-indicator
124
+ edge under a non-locked host instead of running across the screen.
125
+
126
+ ### Removed — pan-guidance overlays no longer public
127
+
128
+ `IncrementalPanGuide` (drift marker) and `PanoramaGuidance` (pan-speed
129
+ pill) are no longer exported, and the `panGuide` / `panoramaGuidance`
130
+ props were removed from `<Camera>`. The components remain in the tree
131
+ as internal-only code (not rendered). Hosts that were passing these
132
+ props should remove them.
133
+
19
134
  ## [0.13.0] — 2026-05-29
20
135
 
21
136
  ### Added — Layer-2 components absorbed into `<Camera>` (opt-out)
package/README.md CHANGED
@@ -8,10 +8,10 @@ AR-backed and IMU-fallback capture paths.
8
8
 
9
9
  | Feature | Behaviour |
10
10
  |---|---|
11
- | **Tap shutter** | Single photo via vision-camera's `takePhoto` (non-AR) or `ARFrame.capturedImage` (AR). |
11
+ | **Tap shutter** | Single photo via vision-camera's `takePhoto` (non-AR) or ARCore/ARKit `capturedImage` (AR). |
12
12
  | **Hold shutter** | Panorama capture — pan and release. Engine accumulates keyframes; stitches via `cv::Stitcher::PANORAMA` (or `SCANS` if the pose suggests a flat-translation scan). |
13
- | **Lens chip** | 1× / 0.5× toggle above the shutter. Selecting 0.5× forces non-AR (AR sessions can't switch physical lenses mid-session). |
14
- | **AR toggle** | Bottom-right corner, conditional on the lens. Toggles between AR-pose-driven and IMU-driven capture paths. |
13
+ | **Lens chip** | 1× / 0.5× toggle next to the shutter. Shown only when the device actually has a usable ultra-wide (real capability detection, v0.14). Hidden entirely in AR-only mode (`captureSources="ar"`). |
14
+ | **Flash & AR pills** | Top-right pill stack, under the settings gear. Flash toggles the torch (hidden on torchless lenses, e.g. a standalone ultra-wide). AR pill toggles AR ↔ non-AR — shown only when `captureSources="both"` and the device supports AR. |
15
15
  | **Internal settings panel** | Opt-in gear icon (top-right) via `showSettingsButton` prop. Exposes blender, seam finder, warper, flow-gate tunables — useful for internal testers; hidden from public consumers by default. |
16
16
 
17
17
  ## Installation
@@ -65,6 +65,15 @@ cd android && ./gradlew :app:assembleDebug # Android
65
65
 
66
66
  ## Quick start
67
67
 
68
+ > **Orientation: use portrait.** `<Camera>` is designed and tuned for
69
+ > portrait capture. On Android it self-locks to portrait; on iOS,
70
+ > portrait-only is the recommended host `Info.plist` configuration.
71
+ > See [Orientation support](#orientation-support) for the full story
72
+ > (landscape *is* supported on iOS if you need it).
73
+
74
+ The minimum: resolve camera permission, then mount `<Camera>` with an
75
+ `onCapture` handler.
76
+
68
77
  ```tsx
69
78
  import {
70
79
  Camera,
@@ -81,83 +90,244 @@ export function CaptureScreen() {
81
90
  'Panorama:',
82
91
  result.uri,
83
92
  `${result.framesIncluded}/${result.framesRequested} frames`,
93
+ `stitched as ${result.stitchModeResolved ?? 'n/a'}`,
84
94
  );
85
95
  }
86
96
  };
87
97
 
88
- const handleError = (err: CameraError) => {
89
- console.warn(err.code, err.message);
90
- };
91
-
92
98
  return (
93
99
  <Camera
94
- defaultCaptureSource="ar"
95
- defaultLens="1x"
96
- enablePhotoMode
97
- enablePanoramaMode
98
100
  onCapture={handleCapture}
99
- onError={handleError}
101
+ onError={(err: CameraError) => console.warn(err.code, err.message)}
100
102
  />
101
103
  );
102
104
  }
103
105
  ```
104
106
 
105
- ## `<Camera>` props (summary)
107
+ ### A complete capture screen
106
108
 
107
- See `src/camera/Camera.tsx` for the full TSDoc. Highlights:
109
+ A realistic screen: requests permission up front, shows a capture
110
+ history strip, opens a post-stitch preview modal, and persists the
111
+ output to a directory you control. (The SDK does **not** request camera
112
+ permission for you — the host owns that.)
108
113
 
109
- ### Initial values (uncontrolled — read once at mount)
114
+ ```tsx
115
+ import React, { useCallback, useEffect, useState } from 'react';
116
+ import { View, StyleSheet } from 'react-native';
117
+ import { SafeAreaProvider } from 'react-native-safe-area-context';
118
+ import { useCameraPermission } from 'react-native-vision-camera';
119
+ import {
120
+ Camera,
121
+ type CameraCaptureResult,
122
+ type CameraError,
123
+ type CaptureThumbnailItem,
124
+ } from 'react-native-image-stitcher';
110
125
 
111
- | Prop | Default | Notes |
112
- |---|---|---|
113
- | `defaultCaptureSource` | `'ar'` | `'ar'` `'non-ar'` |
114
- | `defaultLens` | `'1x'` | `'1x'` `'0.5x'` |
115
- | `defaultStitchMode` | `'auto'` | `'auto'`, `'panorama'`, `'scans'` |
116
- | `defaultBlender` | `'multiband'` | `'multiband'`, `'feather'` |
117
- | `defaultSeamFinder` | `'graphcut'` | `'graphcut'`, `'skip'` |
118
- | `defaultWarper` | `'plane'` | `'plane'`, `'cylindrical'`, `'spherical'` |
119
- | `defaultFlowNoveltyPercentile` | `0.85` | Range 0.50 – 0.99 |
120
- | `defaultFlowEvalEveryNFrames` | `5` | Range 1 10 |
121
- | `defaultFlowMaxTranslationCm` | `50` | 0 = disabled |
122
- | `defaultKeyframeMaxCount` | `6` | Range 3 – 10 |
123
- | `defaultKeyframeOverlapThreshold` | `0.20` | Range 0.20 0.60 |
126
+ export function CaptureScreen() {
127
+ // 1. Camera permission is a HOST concern — resolve it BEFORE mounting
128
+ // <Camera>. (Android treats unrequested permissions as denied even
129
+ // when declared in the manifest, so the explicit call is required.)
130
+ const { hasPermission, requestPermission } = useCameraPermission();
131
+ useEffect(() => {
132
+ if (!hasPermission) requestPermission().catch(() => undefined);
133
+ }, [hasPermission, requestPermission]);
134
+
135
+ // 2. Capture history (drives the built-in thumbnail strip).
136
+ const [thumbnails, setThumbnails] = useState<CaptureThumbnailItem[]>([]);
137
+
138
+ // 3. Post-stitch preview modal set on capture, cleared on close.
139
+ const [preview, setPreview] = useState<CameraCaptureResult | null>(null);
140
+
141
+ const onCapture = useCallback((result: CameraCaptureResult) => {
142
+ setPreview(result);
143
+ setThumbnails((prev) => [
144
+ ...prev,
145
+ { id: String(Date.now()), uri: result.uri, width: result.width, height: result.height },
146
+ ]);
147
+ }, []);
148
+
149
+ if (!hasPermission) return <View style={styles.fill} />; // or your own "grant access" UI
150
+
151
+ return (
152
+ <SafeAreaProvider>
153
+ <View style={styles.fill}>
154
+ <Camera
155
+ // Capture-mode controls
156
+ defaultCaptureSource="ar" // start in AR mode (pose-driven)
157
+ captureSources="both" // allow AR + non-AR; show the AR toggle
158
+ enablePhotoMode // tap = photo
159
+ enablePanoramaMode // hold + pan = panorama
160
+ // Output
161
+ outputDir={`${/* your app dir */ ''}/captures`}
162
+ // Header chrome (optional)
163
+ headerTitle="Capture"
164
+ headerGuidance="Tap for a photo. Hold + pan + release for a panorama."
165
+ // Capture history strip
166
+ thumbnails={thumbnails}
167
+ // Post-stitch preview modal (controlled — clear it on close)
168
+ capturePreview={preview ? { imageUri: preview.uri } : undefined}
169
+ onCapturePreviewClose={() => setPreview(null)}
170
+ // Events
171
+ onCapture={onCapture}
172
+ onError={(err: CameraError) => console.warn(err.code, err.message)}
173
+ onCaptureAbandoned={(reason) => console.log('abandoned:', reason)}
174
+ />
175
+ </View>
176
+ </SafeAreaProvider>
177
+ );
178
+ }
179
+
180
+ const styles = StyleSheet.create({ fill: { flex: 1, backgroundColor: '#000' } });
181
+ ```
182
+
183
+ ## `<Camera>` props (full reference)
184
+
185
+ Every prop is optional. `<Camera>` works with no props at all (it just
186
+ captures and you wire `onCapture`). Props fall into seven groups.
187
+
188
+ > A deeper companion reference with composition recipes lives in
189
+ > [`docs/camera-component.md`](docs/camera-component.md). The tables
190
+ > below are the authoritative prop list.
191
+
192
+ ### Capture-source & lens (uncontrolled — read once at mount)
193
+
194
+ | Prop | Type | Default | Notes |
195
+ |---|---|---|---|
196
+ | `defaultCaptureSource` | `'ar' \| 'non-ar'` | `'ar'` | Initial capture path. Clamped to `captureSources` (below). |
197
+ | `captureSources` | `'ar' \| 'non-ar' \| 'both'` | `'both'` | **(v0.14)** Which sources are allowed. `'both'` shows the AR toggle. `'ar'` hides the AR toggle **and** the lens chooser (ARKit/ARCore can't use the ultra-wide). `'non-ar'` hides the AR toggle, keeps the lens chooser. A single-source value overrides a conflicting `defaultCaptureSource`. |
198
+ | `defaultLens` | `'1x' \| '0.5x'` | `'1x'` | Initial lens. The 0.5× chooser only appears if the device actually has a usable ultra-wide (real capability detection, v0.14). |
199
+
200
+ ### Panorama / stitcher tunables (uncontrolled — internal-tester knobs)
201
+
202
+ These mirror the in-app settings panel; most apps never set them.
203
+
204
+ | Prop | Type | Default | Notes |
205
+ |---|---|---|---|
206
+ | `defaultStitchMode` | `'auto' \| 'panorama' \| 'scans'` | `'auto'` | `'auto'` picks PANORAMA vs SCANS from the pose at finalize. |
207
+ | `defaultBlender` | `'multiband' \| 'feather'` | `'multiband'` | cv::Stitcher blender. |
208
+ | `defaultSeamFinder` | `'graphcut' \| 'skip'` | `'graphcut'` | Seam finder. |
209
+ | `defaultWarper` | `'plane' \| 'cylindrical' \| 'spherical'` | `'plane'` | Projection surface. |
210
+ | `defaultFlowNoveltyPercentile` | `number` | `0.85` | Keyframe-gate novelty threshold (0.50–0.99). |
211
+ | `defaultFlowEvalEveryNFrames` | `number` | `5` | Flow-gate eval cadence (1–10). |
212
+ | `defaultFlowMaxTranslationCm` | `number` | `50` | Max IMU translation between keyframes; 0 = disabled. |
213
+ | `defaultKeyframeMaxCount` | `number` | `6` | Keyframe cap per capture (3–10). |
214
+ | `defaultKeyframeOverlapThreshold` | `number` | `0.20` | Min overlap to accept a keyframe (0.20–0.60). |
215
+ | `defaultCompositingResolMP` / `defaultRegistrationResolMP` / `defaultSeamEstimationResolMP` | `number` | — | Forward-looking cv::Stitcher resolution knobs (currently no-ops). |
124
216
 
125
217
  ### UI toggles
126
218
 
127
- | Prop | Default | Notes |
219
+ | Prop | Type | Default | Notes |
220
+ |---|---|---|---|
221
+ | `enablePhotoMode` | `boolean` | `true` | Tap = photo. When false, tap is a no-op. |
222
+ | `enablePanoramaMode` | `boolean` | `true` | Hold + pan = panorama. When false, hold is a no-op. |
223
+ | `showSettingsButton` | `boolean` | `false` | Gear icon → internal settings panel. Internal-tester only; leave off for public consumers. |
224
+ | `style` | `StyleProp<ViewStyle>` | — | Outer container style. |
225
+
226
+ ### Flash (controlled or uncontrolled)
227
+
228
+ | Prop | Type | Default | Notes |
229
+ |---|---|---|---|
230
+ | `flash` | `'on' \| 'off'` | — | Controlled torch state. Omit to let `<Camera>` own it internally. |
231
+ | `onFlashChange` | `(next: 'on' \| 'off') => void` | — | Fires on flash-button tap (controlled and uncontrolled). |
232
+ | `showFlashButton` | `boolean` | `true` | Built-in flash pill (top-right). Auto-hidden when the mounted device has no torch (e.g. a standalone ultra-wide) and in AR mode. |
233
+
234
+ ### Header chrome (opt-in)
235
+
236
+ Setting `headerTitle` renders a built-in top header; the settings gear is absorbed into it.
237
+
238
+ | Prop | Type | Notes |
128
239
  |---|---|---|
129
- | `enablePhotoMode` | `true` | Tap-to-photo |
130
- | `enablePanoramaMode` | `true` | Hold-to-pan |
131
- | `showSettingsButton` | `false` | Internal-tester only; OFF for public consumers |
240
+ | `headerTitle` | `string` | Shows the header when set. |
241
+ | `headerGuidance` | `string` | Subtitle / guidance pill under the title. |
242
+ | `onHeaderBack` | `() => void` | Renders a back affordance when provided. |
243
+ | `headerBackLabel` | `string` | Custom back-button label. |
244
+ | `headerColors` | `object` | Override header colours. |
132
245
 
133
- ### Callbacks
246
+ ### Capture history + post-stitch preview
134
247
 
135
- | Prop | Fires when |
136
- |---|---|
137
- | `onCapture(result)` | Photo OR panorama capture completes successfully. `result.type` discriminates. |
138
- | `onCaptureSourceChange(source)` | Effective capture source changes (e.g., user toggles AR, or selecting 0. forces non-AR). |
139
- | `onLensChange(lens)` | User taps the 1×/0.5× chip. |
140
- | `onFramesDropped(info)` | cv::Stitcher's confidence retry loop dropped one or more input frames. |
141
- | `onError(err)` | Classified error. `err.code` from a known taxonomy (`STITCH_NEED_MORE_IMGS`, `STITCH_HOMOGRAPHY_FAIL`, `STITCH_CAMERA_PARAMS_FAIL`, `STITCH_OOM`, `CAMERA_PERMISSION_DENIED`, etc.). |
248
+ | Prop | Type | Notes |
249
+ |---|---|---|
250
+ | `thumbnails` | `CaptureThumbnailItem[]` | When supplied (even `[]`), renders the built-in thumbnail strip. Hidden during recording. |
251
+ | `thumbnailsMin` / `thumbnailsMax` | `number` | Optional count-line hints (e.g. quota guidance). |
252
+ | `onThumbnailPress` | `(item) => void` | Replaces the strip's built-in tap-to-preview with your handler. |
253
+ | `capturePreview` | `{ imageUri; imageWidth?; imageHeight?; title? }` | When set, renders the built-in preview modal. Controlled — clear it via `onCapturePreviewClose`. |
254
+ | `capturePreviewActions` | `CapturePreviewAction[]` | Action buttons for the preview modal (e.g. Save / Retake). |
255
+ | `onCapturePreviewClose` | `() => void` | Fires when the preview modal is dismissed. |
142
256
 
143
- ## Orientation support
257
+ ### Callbacks & advanced
258
+
259
+ | Prop | Type | Fires / purpose |
260
+ |---|---|---|
261
+ | `onCapture` | `(result: CameraCaptureResult) => void` | Photo OR panorama completes. `result.type` discriminates (`'photo'` / `'panorama'`). |
262
+ | `onCaptureSourceChange` | `(source: CaptureSource) => void` | Effective source changes (AR toggle, or 0.5× forcing non-AR). |
263
+ | `onLensChange` | `(lens: CameraLens) => void` | User taps the 1×/0.5× chip. |
264
+ | `onFramesDropped` | `(info: FramesDroppedInfo) => void` | cv::Stitcher's confidence retry dropped input frame(s). |
265
+ | `onCaptureAbandoned` | `(reason: 'orientation-drift') => void` | SDK auto-cancelled an in-flight capture (currently only mid-capture rotation). |
266
+ | `onError` | `(err: CameraError) => void` | Classified error — see codes below. |
267
+ | `outputDir` | `string` | Directory for saved JPEGs. The lib creates it if missing. |
268
+ | `engine` | `'batch-keyframe' \| …` | Stitching engine. Default `'batch-keyframe'`; most apps leave it. |
269
+ | `frameProcessor` | vision-camera frame processor | Host worklet composed with first-party stitching (see [`useStitcherWorklet`](docs/camera-component.md)). Advanced. |
270
+
271
+ ### `CameraCaptureResult`
272
+
273
+ ```ts
274
+ type CameraCaptureResult =
275
+ | { type: 'photo'; uri: string; width: number; height: number }
276
+ | { type: 'panorama'; uri: string; width: number; height: number;
277
+ framesRequested: number; framesIncluded: number; framesDropped: number;
278
+ finalConfidenceThresh: number; durationMs: number;
279
+ stitchModeResolved?: 'panorama' | 'scans' };
280
+ ```
281
+
282
+ ### `CameraError` codes
144
283
 
145
- `<Camera>` works in any device orientation regardless of host
146
- configuration. No host setup required — the SDK adapts at runtime.
284
+ `err.code` is one of a fixed taxonomy so you can branch (toast vs retry vs report):
285
+ `CAMERA_PERMISSION_DENIED`, `CAMERA_DEVICE_UNAVAILABLE`, `PHOTO_CAPTURE_FAILED`,
286
+ `PANORAMA_START_FAILED`, `PANORAMA_FINALIZE_FAILED`, `STITCH_NEED_MORE_IMGS`,
287
+ `STITCH_HOMOGRAPHY_FAIL`, `STITCH_CAMERA_PARAMS_FAIL`, `STITCH_OOM`,
288
+ `OUTPUT_WRITE_FAILED`, plus `VISION_CAMERA_RUNTIME`.
147
289
 
148
- **Portrait-locked host** (Info.plist `UISupportedInterfaceOrientations`
149
- restricted to Portrait — recommended for kiosks / single-task apps):
150
- the screen stays portrait; the SDK uses sensor-derived orientation
151
- for capture-mode selection and overlay layout. This is the simpler
152
- configuration and the historical default.
290
+ ### Migration from 0.13.x
153
291
 
154
- **Non-locked host** (Info.plist supports all 4 orientations — recommended
155
- for apps with other landscape-friendly screens): the screen rotates
156
- with the device. `<Camera>`'s controls (shutter, lens chip, AR toggle)
157
- anchor to the home-indicator edge so they stay within thumb reach
158
- regardless of tilt — matching iOS Camera's behaviour. The
159
- orientation-aware logic combines `useWindowDimensions()` (JS-layout)
160
- with `useDeviceOrientation()` (sensor) to compute the correct anchor.
292
+ - **Removed:** the `panGuide` and `panoramaGuidance` props (the
293
+ drift-marker overlay + pan-speed pill). They are no longer part of the
294
+ public API and `<Camera>` no longer renders them. Remove these props
295
+ if you were passing them they're now a no-op type error.
296
+ - **Added:** `captureSources` (above).
297
+ - **Behaviour:** flash + AR controls moved to a top-right pill stack; the
298
+ 0.5× chooser now reflects real device capability; Android self-locks to
299
+ portrait. No code change required for any of these.
300
+
301
+ ## Orientation support
302
+
303
+ > **Recommended: portrait.** `<Camera>` is designed and tuned for
304
+ > portrait capture, and that is the recommended way to use it on both
305
+ > platforms. Landscape is supported on iOS for hosts that need it
306
+ > (see below); on Android the camera is always portrait.
307
+
308
+ **Android — always portrait (SDK-enforced).** On Android `<Camera>`
309
+ locks its host Activity to portrait while mounted (via
310
+ `Activity.setRequestedOrientation`), **regardless of the host app's
311
+ manifest** — even a fully landscape or unlocked host gets a portrait
312
+ camera screen. The prior orientation is restored when `<Camera>`
313
+ unmounts. No host setup is required and there is no opt-out: Android
314
+ capture is portrait-only by design.
315
+
316
+ **iOS — portrait recommended, landscape supported.** iOS supported
317
+ orientations are owned by the host's `Info.plist`
318
+ (`UISupportedInterfaceOrientations`); the SDK does not override them.
319
+
320
+ - *Portrait-only host* (Info.plist = Portrait — **recommended**): the
321
+ screen stays portrait; the SDK uses sensor-derived orientation for
322
+ capture-mode selection and overlay layout. Simplest configuration.
323
+ - *Non-locked host* (Info.plist supports all 4 — supported for apps
324
+ with other landscape-friendly screens): the screen rotates with the
325
+ device. `<Camera>`'s controls (shutter, lens chip, AR toggle) and
326
+ the live thumbnail strip/band anchor to the home-indicator edge so
327
+ they stay within thumb reach regardless of tilt — matching iOS
328
+ Camera's behaviour. The orientation-aware logic combines
329
+ `useWindowDimensions()` (JS-layout) with `useDeviceOrientation()`
330
+ (sensor) to compute the correct anchor.
161
331
 
162
332
  **Mid-capture rotation safety** — the incremental engine doesn't
163
333
  support cross-orientation captures (a portrait capture's keyframes
@@ -170,14 +340,20 @@ the modal alone.
170
340
 
171
341
  ## Lens ↔ AR interaction
172
342
 
173
- | Action | `arPreference` | `lens` | UI |
174
- |---|---|---|---|
175
- | Initial mount with defaults | `true` | `1x` | AR toggle ON |
176
- | User switches to 0.5× | unchanged (`true`) | `0.5x` | AR toggle HIDDEN, forced non-AR |
177
- | User switches back to 1× | unchanged (`true`) | `1x` | AR toggle visible at its previous state |
178
- | User taps AR toggle off (on 1×) | `false` | `1x` | AR toggle OFF |
343
+ The lens chooser and AR toggle interact, because ARKit/ARCore sessions
344
+ can't switch to the ultra-wide. With `captureSources="both"` (default):
179
345
 
180
- The component owns the runtime state; the parent persists across launches via the `on*Change` callbacks if desired.
346
+ | Action | AR preference | Lens | UI |
347
+ |---|---|---|---|
348
+ | Initial mount (defaults) | on | `1×` | AR pill ON |
349
+ | Switch to 0.5× | unchanged | `0.5×` | AR pill HIDDEN; capture forced non-AR |
350
+ | Switch back to 1× | unchanged | `1×` | AR pill visible at its previous state |
351
+ | Tap AR pill off (on 1×) | off | `1×` | AR pill OFF |
352
+
353
+ When `captureSources` is `'ar'` or `'non-ar'`, the AR pill never shows
354
+ (nothing to toggle), and `'ar'` additionally hides the lens chooser. The
355
+ component owns this runtime state; persist across launches via the
356
+ `on*Change` callbacks if desired.
181
357
 
182
358
  ## Architecture notes
183
359
 
@@ -89,6 +89,14 @@ class RNSARCameraView @JvmOverloads constructor(
89
89
  internal data class TakePhotoRequest(
90
90
  val outputPath: String,
91
91
  val quality: Int,
92
+ // v0.13.2 — physical device orientation at capture time, from the
93
+ // JS `useDeviceOrientation()` hook (one of 'portrait' /
94
+ // 'portrait-upside-down' / 'landscape-left' / 'landscape-right').
95
+ // Used INSTEAD of the window display rotation so AR photos come
96
+ // out upright even under a PORTRAIT-LOCKED host (where the window
97
+ // rotation is always ROTATION_0 regardless of how the device is
98
+ // held — the cause of the "landscape AR photo is sideways" bug).
99
+ val orientation: String,
92
100
  val promise: com.facebook.react.bridge.Promise,
93
101
  )
94
102
  private val pendingTakePhoto =
@@ -101,9 +109,10 @@ class RNSARCameraView @JvmOverloads constructor(
101
109
  internal fun requestTakePhoto(
102
110
  outputPath: String,
103
111
  quality: Int,
112
+ orientation: String,
104
113
  promise: com.facebook.react.bridge.Promise,
105
114
  ) {
106
- val req = TakePhotoRequest(outputPath, quality, promise)
115
+ val req = TakePhotoRequest(outputPath, quality, orientation, promise)
107
116
  val previous = pendingTakePhoto.getAndSet(req)
108
117
  previous?.promise?.reject(
109
118
  "ar-photo-superseded",
@@ -339,10 +348,15 @@ class RNSARCameraView @JvmOverloads constructor(
339
348
  image,
340
349
  req.outputPath,
341
350
  jpegQuality = req.quality.coerceIn(1, 100),
342
- displayRotation = if (lastDisplayRotation >= 0)
343
- lastDisplayRotation
344
- else
345
- Surface.ROTATION_0,
351
+ // v0.13.2 derive the encode rotation from the PHYSICAL
352
+ // device orientation (JS gyro), not the window display
353
+ // rotation. Under a portrait-locked host the window stays
354
+ // ROTATION_0 regardless of how the device is held, so the
355
+ // old `lastDisplayRotation` path baked a portrait EXIF tag
356
+ // onto landscape captures → sideways photo. The
357
+ // device-orientation → Surface.ROTATION_* mapping below
358
+ // feeds encodeToJpeg's existing EXIF table.
359
+ displayRotation = deviceOrientationToSurfaceRotation(req.orientation),
346
360
  )
347
361
  if (written == null) {
348
362
  req.promise.reject(
@@ -632,6 +646,20 @@ class RNSARCameraView @JvmOverloads constructor(
632
646
  )
633
647
  }
634
648
 
649
+ /// v0.13.2 — map the JS physical device orientation to the
650
+ /// `Surface.ROTATION_*` value `YuvImageConverter.encodeToJpeg`
651
+ /// expects. Mirrors the equivalence documented in encodeToJpeg's
652
+ /// EXIF table (ROTATION_0=portrait, _90=landscape-left,
653
+ /// _180=portrait-upside-down, _270=landscape-right). Unknown /
654
+ /// missing → portrait (the safe pre-v0.12 default).
655
+ private fun deviceOrientationToSurfaceRotation(orientation: String): Int =
656
+ when (orientation) {
657
+ "landscape-left" -> Surface.ROTATION_90
658
+ "portrait-upside-down" -> Surface.ROTATION_180
659
+ "landscape-right" -> Surface.ROTATION_270
660
+ else -> Surface.ROTATION_0 // "portrait" + fallback
661
+ }
662
+
635
663
  private fun applyDisplayGeometry() {
636
664
  val session = sessionRef.get() ?: return
637
665
  val rotation = (context.getSystemService(Context.WINDOW_SERVICE) as? WindowManager)
@@ -3,6 +3,7 @@ package io.imagestitcher.rn
3
3
 
4
4
  import android.app.Activity
5
5
  import android.content.Context
6
+ import android.content.pm.ActivityInfo
6
7
  import android.util.Log
7
8
  import com.facebook.react.bridge.Arguments
8
9
  import com.facebook.react.bridge.Promise
@@ -58,6 +59,65 @@ class RNSARSession(reactContext: ReactApplicationContext)
58
59
  private val poseLog = mutableListOf<RNSARFramePose>()
59
60
  private val poseLogLock = ReentrantReadWriteLock()
60
61
 
62
+ // ── v0.13.1 — Android <Camera> portrait lock ────────────────────
63
+ //
64
+ // Unlike iOS (where supported orientations are a static Info.plist
65
+ // declaration the app can't override per-view at runtime), Android
66
+ // lets a view force its host Activity's orientation via
67
+ // `Activity.requestedOrientation`. The SDK's `<Camera>` uses this
68
+ // to guarantee a portrait capture surface regardless of the host
69
+ // app's manifest — even a fully landscape/unlocked host gets a
70
+ // portrait camera while `<Camera>` is mounted.
71
+ //
72
+ // `lockPortrait()` is called from `Camera.tsx`'s mount effect and
73
+ // covers BOTH capture paths (AR ARCore view + non-AR vision-camera)
74
+ // because the lock lives on the Activity, not on either camera view.
75
+ // `unlockOrientation()` (mount-effect cleanup) restores the EXACT
76
+ // orientation the Activity had before we locked, so hosts with
77
+ // mixed-orientation screens get their prior setting back rather than
78
+ // a generic default.
79
+ //
80
+ // SCREEN_ORIENTATION_UNSET (-2) is our "nothing captured yet"
81
+ // sentinel; we never pass it to setRequestedOrientation.
82
+ private var priorRequestedOrientation: Int = ORIENTATION_UNSET
83
+
84
+ @ReactMethod
85
+ fun lockPortrait() {
86
+ val activity: Activity = reactApplicationContext.currentActivity ?: run {
87
+ Log.w(TAG, "lockPortrait: no current activity — skipping")
88
+ return
89
+ }
90
+ activity.runOnUiThread {
91
+ // Capture the prior value ONCE (first lock wins). Guards
92
+ // against a remount double-capturing our own portrait value
93
+ // and losing the host's real prior orientation.
94
+ if (priorRequestedOrientation == ORIENTATION_UNSET) {
95
+ priorRequestedOrientation = activity.requestedOrientation
96
+ }
97
+ activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
98
+ }
99
+ }
100
+
101
+ @ReactMethod
102
+ fun unlockOrientation() {
103
+ val activity: Activity = reactApplicationContext.currentActivity ?: run {
104
+ Log.w(TAG, "unlockOrientation: no current activity — skipping")
105
+ return
106
+ }
107
+ activity.runOnUiThread {
108
+ if (priorRequestedOrientation != ORIENTATION_UNSET) {
109
+ activity.requestedOrientation = priorRequestedOrientation
110
+ priorRequestedOrientation = ORIENTATION_UNSET
111
+ } else {
112
+ // No capture on record (lock never ran or already
113
+ // restored) — fall back to UNSPECIFIED so we don't pin
114
+ // the host to whatever we last set.
115
+ activity.requestedOrientation =
116
+ ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
117
+ }
118
+ }
119
+ }
120
+
61
121
  @ReactMethod
62
122
  fun isSupported(promise: Promise) {
63
123
  // `checkAvailability` can return UNKNOWN_CHECKING if the
@@ -395,6 +455,13 @@ class RNSARSession(reactContext: ReactApplicationContext)
395
455
  }
396
456
  val rawPath = if (options.hasKey("path")) options.getString("path") ?: "" else ""
397
457
  val quality = if (options.hasKey("quality")) options.getInt("quality") else 90
458
+ // v0.13.2 — physical device orientation from JS (useDeviceOrientation).
459
+ // Drives the saved JPEG's rotation so landscape AR captures are
460
+ // upright even under a portrait-locked host. Defaults to
461
+ // 'portrait' (pre-v0.12 behaviour) when the host omits it.
462
+ val orientation =
463
+ if (options.hasKey("orientation")) options.getString("orientation") ?: "portrait"
464
+ else "portrait"
398
465
  val resolvedPath: String = if (rawPath.isNotEmpty()) {
399
466
  rawPath
400
467
  } else {
@@ -404,7 +471,7 @@ class RNSARSession(reactContext: ReactApplicationContext)
404
471
  "RNImageStitcher-ar-${java.util.UUID.randomUUID()}.jpg",
405
472
  ).absolutePath
406
473
  }
407
- view.requestTakePhoto(resolvedPath, quality, promise)
474
+ view.requestTakePhoto(resolvedPath, quality, orientation, promise)
408
475
  }
409
476
 
410
477
  @ReactMethod
@@ -776,6 +843,11 @@ class RNSARSession(reactContext: ReactApplicationContext)
776
843
  private const val TAG = "RNSARSession"
777
844
  private const val MAX_POSE_LOG = 600 // ~10 s @ 60Hz
778
845
 
846
+ // Sentinel for "no prior Activity orientation captured yet".
847
+ // Distinct from any real ActivityInfo.SCREEN_ORIENTATION_*
848
+ // value (those are >= -1); -2 is unused by the framework.
849
+ private const val ORIENTATION_UNSET = -2
850
+
779
851
  /**
780
852
  * Convenience accessor for the AR camera view (in Phase 4.4)
781
853
  * to reach the singleton-installed module instance. We use