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
package/CHANGELOG.md CHANGED
@@ -16,6 +16,187 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
16
16
 
17
17
  ## [Unreleased]
18
18
 
19
+ ## [0.14.0] — 2026-06-01
20
+
21
+ ### Fixed — Android AR single-photo orientation (landscape was sideways)
22
+
23
+ Android AR `takePhoto` baked the wrong rotation into landscape captures
24
+ under a portrait-locked host: it derived the EXIF orientation from the
25
+ window display rotation (`WindowManager.defaultDisplay.rotation`), which
26
+ stays `ROTATION_0` when the activity is portrait-locked regardless of how
27
+ the device is physically held — so a landscape photo got a portrait EXIF
28
+ tag and came out 90° CW. The JS layer already passed the gyro device
29
+ orientation to `RNSARSession.takePhoto` (since v0.12), and iOS consumed
30
+ it, but the Android native side dropped it. Now Android threads the
31
+ device orientation through `takePhoto → requestTakePhoto → encodeToJpeg`,
32
+ mapping it to the correct `Surface.ROTATION_*` / EXIF tag. iOS unchanged
33
+ (already correct). Verified on-device (Samsung A35) in both landscape
34
+ orientations.
35
+
36
+ ### Added — `captureSources` constraint prop
37
+
38
+ `<Camera>` gains `captureSources?: 'ar' | 'non-ar' | 'both'` (default
39
+ `'both'`) — a constraint on which capture sources the host allows, layered
40
+ over `defaultCaptureSource` (which picks the initial source within it):
41
+
42
+ - `'both'` — AR + non-AR; the runtime AR toggle is shown (unchanged
43
+ default behaviour).
44
+ - `'ar'` — AR only; the AR toggle is hidden (nothing to switch to) and
45
+ the 0.5×/1× lens chooser is hidden (ARKit/ARCore can't use the
46
+ ultra-wide), keeping capture on the AR-capable 1× lens.
47
+ - `'non-ar'`— non-AR only; the AR toggle is hidden, the lens chooser stays.
48
+
49
+ A single-source constraint overrides a conflicting `defaultCaptureSource`.
50
+ Exported type: `CaptureSourcesMode`. Verified on-device (A35) across all
51
+ three modes.
52
+
53
+ ### Fixed — capability-aware lens selection (ultra-wide + flash on 0.5×)
54
+
55
+ `<Camera>` now selects the back camera device by real capability instead
56
+ of requesting a single physical lens per zoom level. `selectCaptureDevice`:
57
+
58
+ - **Prefers a multi-cam device** that spans wide + ultra-wide (lens
59
+ switched via `zoom`; torch available on every lens). On devices that
60
+ expose such a device (e.g. iPhone 16 Pro — verified `multicam`), this
61
+ fixes the user-reported "0.5× shows the wide-angle FOV" bug AND makes
62
+ flash work on 0.5× (the mounted multi-cam device carries the torch).
63
+ - **Falls back to a standalone ultra-wide** device-swap where no multi-cam
64
+ device exists (e.g. Samsung A35 — verified `standalone-uw`; vision-camera
65
+ surfaces the physical cameras separately there). 0.5× still shows the
66
+ ultra-wide FOV; flash hides because that standalone device is torchless.
67
+
68
+ `has0_5x` is now derived from the real device inventory (was hardcoded
69
+ `true`), so the lens chooser hides on wide-only hardware. 13 unit tests
70
+ cover the selection matrix incl. both edge cases (ultra-wide only in a
71
+ multi-cam group; ultra-wide only standalone).
72
+
73
+ Verified on-device: iPhone 16 Pro (multicam — 0.5× FOV + flash both work)
74
+ and Samsung A35 (standalone-uw — 0.5× FOV works, flash correctly hidden).
75
+
76
+ ### Added — Android portrait lock (SDK-enforced)
77
+
78
+ `<Camera>` now locks its host Activity to portrait on Android while
79
+ mounted, via `Activity.setRequestedOrientation`, **regardless of the
80
+ host app's `AndroidManifest` `screenOrientation`**. A landscape or
81
+ unlocked host still gets a portrait camera screen. The Activity's
82
+ prior orientation is captured on mount and restored on unmount.
83
+ Implemented in the native `RNSARSession` module (`lockPortrait()` /
84
+ `unlockOrientation()`) and driven from a `<Camera>` mount effect, so
85
+ it covers both the AR (ARCore) and non-AR (vision-camera) paths.
86
+ There is no opt-out — Android capture is portrait-only by design.
87
+
88
+ iOS is intentionally unchanged: supported orientations remain owned by
89
+ the host `Info.plist`. **Portrait is the recommended configuration on
90
+ both platforms; landscape is supported on iOS** for hosts that need it.
91
+
92
+ ### Fixed — landscape preview + thumbnail orientation (non-locked iOS)
93
+
94
+ - **Preview squish / sideways** under a non-locked host was caused by
95
+ an in-development `patch-package` patch to vision-camera's
96
+ `OrientationManager` (both `.kt` and `.swift`) that derived the
97
+ PREVIEW orientation from the accelerometer instead of the interface
98
+ orientation. In a portrait host held landscape this forced a
99
+ landscape preview into a portrait surface. The patch was removed and
100
+ vision-camera restored to pristine on both platforms.
101
+ - **Band keyframe thumbnails rotated 90°**: the per-keyframe tiles in
102
+ `PanoramaBandOverlay` were double-rotated — the saved `keyframe-N.jpg`
103
+ is sensor-native landscape + EXIF Orientation 6, which `<Image>`
104
+ already auto-rotates, so the extra JS transform was redundant in the
105
+ portrait-locked (`vertical=false`) path. The transform is now applied
106
+ only in the `vertical=true` (non-locked landscape) path.
107
+ - **Stitched-preview / confirm modals stuck portrait**: `CapturePreview`
108
+ and `PanoramaConfirmModal` were missing `supportedOrientations`
109
+ (RN's iOS `<Modal>` defaults to portrait-only). Both now declare all
110
+ four, matching `OrientationDriftModal` + `PanoramaSettingsModal`.
111
+ - **Idle thumbnail strip horizontal in landscape**: `CaptureThumbnailStrip`
112
+ gained a `vertical` prop (wired from the same `isSideEdge` signal as
113
+ the band) so the idle strip stacks vertically along the home-indicator
114
+ edge under a non-locked host instead of running across the screen.
115
+
116
+ ### Removed — pan-guidance overlays no longer public
117
+
118
+ `IncrementalPanGuide` (drift marker) and `PanoramaGuidance` (pan-speed
119
+ pill) are no longer exported, and the `panGuide` / `panoramaGuidance`
120
+ props were removed from `<Camera>`. The components remain in the tree
121
+ as internal-only code (not rendered). Hosts that were passing these
122
+ props should remove them.
123
+
124
+ ## [0.13.0] — 2026-05-29
125
+
126
+ ### Added — Layer-2 components absorbed into `<Camera>` (opt-out)
127
+
128
+ The flagship `<Camera>` now ships built-in defaults for every UX
129
+ chrome piece previously exposed only as a Layer-2 component. Hosts
130
+ adopting `<Camera>` directly get a complete capture surface — flash
131
+ button, pan-speed pill, drift-marker guide, header chrome,
132
+ capture-history strip, and post-stitch preview — without having to
133
+ import and wire each piece by hand.
134
+
135
+ All built-ins use the opt-out pattern: enabled by default, disabled
136
+ by setting the corresponding boolean to `false` or by omitting the
137
+ corresponding payload prop. Hosts that want their own chrome can
138
+ opt out per piece and layer custom UI on top of `<Camera>` (the
139
+ Layer-2 components remain exported and are unchanged).
140
+
141
+ #### Flash control
142
+
143
+ - `flash?: 'on' | 'off'` — controlled torch state. Omit to let
144
+ `<Camera>` own it internally.
145
+ - `onFlashChange?` — fires on tap (controlled and uncontrolled both).
146
+ - `showFlashButton?: boolean` (default `true`) — built-in flash button
147
+ in the bottom-left slot. AR mode auto-disables (ARKit / ARCore own
148
+ the device's torch; surfaces "Flash unavailable in AR mode" a11y
149
+ label and greyed styling).
150
+
151
+ #### Pan guidance
152
+
153
+ - `panGuide?: boolean` (default `true`) — built-in
154
+ `IncrementalPanGuide` ("keep the arrow on the line" drift marker).
155
+ - `panoramaGuidance?: boolean` (default `true`) — built-in
156
+ `PanoramaGuidance` pan-speed pill.
157
+ - Both are gyroscope-driven and only subscribe to the sensor while
158
+ recording — no idle cost.
159
+
160
+ #### Header
161
+
162
+ - `headerTitle?: string` — when set, renders a built-in
163
+ `CaptureHeader` at the top of the screen. The existing settings
164
+ gear is absorbed into the header's right side (no duplicate gear).
165
+ - `onHeaderBack?`, `headerBackLabel?`, `headerGuidance?`,
166
+ `headerColors?` — pass-through to `CaptureHeader`.
167
+
168
+ #### Capture history + preview
169
+
170
+ - `thumbnails?: CaptureThumbnailItem[]` — when supplied (even `[]`),
171
+ renders the built-in `CaptureThumbnailStrip` above the bottom
172
+ controls. Hidden during recording so it doesn't overlap the
173
+ panorama band overlay.
174
+ - `thumbnailsMin?`, `thumbnailsMax?` — count-line hints.
175
+ - `onThumbnailPress?` — replaces the strip's built-in
176
+ tap-to-preview modal with a host handler.
177
+ - `capturePreview?` — when set, renders a built-in `CapturePreview`
178
+ modal showing the supplied image. Use for post-stitch
179
+ confirmation; the host clears the prop on dismiss via
180
+ `onCapturePreviewClose`.
181
+ - `capturePreviewActions?` — pass-through action buttons for the
182
+ preview modal.
183
+
184
+ ### Migration
185
+
186
+ - Hosts that were importing Layer-2 components (`CaptureHeader`,
187
+ `CaptureControlsBar`, `IncrementalPanGuide`, `PanoramaGuidance`,
188
+ `CaptureThumbnailStrip`, `CapturePreview`) directly can now drop
189
+ those imports and use the corresponding `<Camera>` props.
190
+ - The Layer-2 components remain exported and unchanged in v0.13 for
191
+ backward compatibility. Deprecation of those exports is targeted
192
+ for v0.14.
193
+ - No behaviour change for hosts that already use `<Camera>` and
194
+ don't supply any of the new props — every new built-in defaults
195
+ to the previous (omitted) UX, except the flash button which
196
+ appears in the now-occupied bottom-left slot. Hosts that previously
197
+ rendered chrome in that slot above `<Camera>` can pass
198
+ `showFlashButton={false}`.
199
+
19
200
  ## [0.12.0] — 2026-05-28
20
201
 
21
202
  ### Added — Orientation-aware `<Camera>` (R2-lite)
package/README.md CHANGED
@@ -104,7 +104,11 @@ export function CaptureScreen() {
104
104
 
105
105
  ## `<Camera>` props (summary)
106
106
 
107
- See `src/camera/Camera.tsx` for the full TSDoc. Highlights:
107
+ > **Full reference:** [`docs/camera-component.md`](docs/camera-component.md)
108
+ > covers every prop with purpose, default, behaviour notes, the
109
+ > `CameraCaptureResult` / `CameraError` shapes, orientation
110
+ > behaviour, and common compositions. This README summary lists
111
+ > the highlights only.
108
112
 
109
113
  ### Initial values (uncontrolled — read once at mount)
110
114
 
@@ -142,22 +146,34 @@ See `src/camera/Camera.tsx` for the full TSDoc. Highlights:
142
146
 
143
147
  ## Orientation support
144
148
 
145
- `<Camera>` works in any device orientation regardless of host
146
- configuration. No host setup required the SDK adapts at runtime.
147
-
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.
153
-
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.
149
+ > **Recommended: portrait.** `<Camera>` is designed and tuned for
150
+ > portrait capture, and that is the recommended way to use it on both
151
+ > platforms. Landscape is supported on iOS for hosts that need it
152
+ > (see below); on Android the camera is always portrait.
153
+
154
+ **Android always portrait (SDK-enforced).** On Android `<Camera>`
155
+ locks its host Activity to portrait while mounted (via
156
+ `Activity.setRequestedOrientation`), **regardless of the host app's
157
+ manifest** — even a fully landscape or unlocked host gets a portrait
158
+ camera screen. The prior orientation is restored when `<Camera>`
159
+ unmounts. No host setup is required and there is no opt-out: Android
160
+ capture is portrait-only by design.
161
+
162
+ **iOS portrait recommended, landscape supported.** iOS supported
163
+ orientations are owned by the host's `Info.plist`
164
+ (`UISupportedInterfaceOrientations`); the SDK does not override them.
165
+
166
+ - *Portrait-only host* (Info.plist = Portrait — **recommended**): the
167
+ screen stays portrait; the SDK uses sensor-derived orientation for
168
+ capture-mode selection and overlay layout. Simplest configuration.
169
+ - *Non-locked host* (Info.plist supports all 4 — supported for apps
170
+ with other landscape-friendly screens): the screen rotates with the
171
+ device. `<Camera>`'s controls (shutter, lens chip, AR toggle) and
172
+ the live thumbnail strip/band anchor to the home-indicator edge so
173
+ they stay within thumb reach regardless of tilt — matching iOS
174
+ Camera's behaviour. The orientation-aware logic combines
175
+ `useWindowDimensions()` (JS-layout) with `useDeviceOrientation()`
176
+ (sensor) to compute the correct anchor.
161
177
 
162
178
  **Mid-capture rotation safety** — the incremental engine doesn't
163
179
  support cross-orientation captures (a portrait capture's keyframes
@@ -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