react-native-image-stitcher 0.1.1 → 0.1.3

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 (35) hide show
  1. package/CHANGELOG.md +115 -1
  2. package/README.md +0 -9
  3. package/RNImageStitcher.podspec +10 -4
  4. package/android/src/main/cpp/keyframe_gate_jni.cpp +1 -1
  5. package/android/src/main/java/io/imagestitcher/rn/BatchStitcher.kt +1 -1
  6. package/android/src/main/java/io/imagestitcher/rn/FileBridge.kt +79 -0
  7. package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +11 -3
  8. package/android/src/main/java/io/imagestitcher/rn/KeyframeGate.kt +1 -1
  9. package/android/src/main/java/io/imagestitcher/rn/RNImageStitcherPackage.kt +1 -0
  10. package/dist/camera/Camera.d.ts +29 -1
  11. package/dist/camera/Camera.js +47 -37
  12. package/dist/camera/useCapture.d.ts +31 -1
  13. package/dist/camera/useCapture.js +23 -2
  14. package/dist/index.d.ts +1 -0
  15. package/dist/stitching/stitchFrames.d.ts +1 -1
  16. package/dist/stitching/stitchFrames.js +1 -1
  17. package/dist/utils/files.d.ts +44 -0
  18. package/dist/utils/files.js +84 -0
  19. package/dist/utils/paths.d.ts +30 -0
  20. package/dist/utils/paths.js +48 -0
  21. package/ios/Package.swift +1 -1
  22. package/ios/Sources/RNImageStitcher/FileBridge.m +20 -0
  23. package/ios/Sources/RNImageStitcher/FileBridge.swift +110 -0
  24. package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +4 -4
  25. package/ios/Sources/RNImageStitcher/KeyframeGate.swift +1 -1
  26. package/ios/Sources/RNImageStitcher/KeyframeGateBridge.h +1 -1
  27. package/ios/Sources/RNImageStitcher/KeyframeGateBridge.mm +1 -1
  28. package/ios/Sources/RNImageStitcher/RNSARSession.swift +1 -1
  29. package/package.json +1 -1
  30. package/src/camera/Camera.tsx +85 -35
  31. package/src/camera/useCapture.ts +62 -3
  32. package/src/index.ts +1 -0
  33. package/src/stitching/stitchFrames.ts +1 -1
  34. package/src/utils/files.ts +97 -0
  35. package/src/utils/paths.ts +42 -0
package/CHANGELOG.md CHANGED
@@ -16,6 +16,107 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
16
16
 
17
17
  ## [Unreleased]
18
18
 
19
+ ## [0.1.3] — 2026-05-21
20
+
21
+ ### Changed
22
+
23
+ - **Docs / source-comment cleanup.** Removed the leftover
24
+ pre-extraction RetaiLens-monorepo framing from the README — this repo
25
+ is now the canonical, self-contained source of `react-native-image-
26
+ stitcher`, not a downstream subtree of anything. Source-file path
27
+ comments and iOS GCD queue labels now use the canonical
28
+ `io.imagestitcher.*` namespace and `react-native-image-stitcher/`
29
+ repo path instead of the leftover `com.retailens.*` /
30
+ `retailens-capture-sdk/` references that survived the 0.1.0 rename.
31
+ GCD label change affects: `RNSARSession.poseLogQueue`,
32
+ `IncrementalStitcher.workQueue`, `IncrementalStitcher.refineQueue` —
33
+ labels are diagnostic-only (Instruments / crash-report symbolication),
34
+ no public-API or behaviour impact. The CHANGELOG.md migration table
35
+ for [0.1.0] retains the historical `com.retailens.capturesdk` name
36
+ intentionally — it documents the rename that shipped, not the
37
+ current state.
38
+ - **CHANGELOG.** Added compare-links for [0.1.1] and [0.1.2] and fixed
39
+ the [Unreleased] compare base. Annotated the [0.1.0] "Deliberately
40
+ NOT exported" section with a header note explaining that most of
41
+ those entries were promoted to public in [0.1.1] — see the 0.1.1
42
+ *Added* list for the current public surface.
43
+
44
+ ## [0.1.2] — 2026-05-20
45
+
46
+ ### Added
47
+
48
+ - **`outputDir` prop on `<Camera>`** + **`outputPath` per-call
49
+ option on `useCapture.takePhoto`** — captures (both tap-photos
50
+ and hold-panoramas) can now land at a host-controlled file
51
+ location instead of vision-camera's tmp dir. Filename is
52
+ composed internally as `${outputDir}/photo-${ts}.jpg` /
53
+ `${outputDir}/panorama-${ts}.jpg` for `<Camera>`; per-call
54
+ `outputPath` on `useCapture` lets layer-2 hosts compose their
55
+ own filenames.
56
+ - On disk failure the capture rejects with
57
+ `CameraError('OUTPUT_WRITE_FAILED', ...)`. **No silent
58
+ fallback** to a different path — that hides bugs.
59
+ - Host owns *picking* the path. The lib treats the value as an
60
+ opaque writable filesystem path; it does not know about iOS
61
+ `UIFileSharingEnabled`, Android MediaStore, SAF, or any other
62
+ platform-specific shared-storage mechanism. That's the host's
63
+ domain.
64
+ - **No peer deps required** — the move is handled by a small
65
+ native bridge (`RNImageStitcherFileUtils`) that ships with
66
+ the lib.
67
+ - **Canonical default capture directory** — when neither
68
+ `outputDir` nor `outputPath` is set, the lib now writes captures
69
+ to a predictable per-platform location instead of vision-camera's
70
+ auto-generated tmp paths:
71
+ - iOS: `<NSCachesDirectory>/react-native-image-stitcher/photo-<ms>.jpg`
72
+ (and `panorama-<ms>.jpg`).
73
+ - Android: `<context.cacheDir>/react-native-image-stitcher/...`.
74
+ - Both are app-private, evictable by the OS under storage
75
+ pressure, not backed up. Captures live here until the host
76
+ moves them somewhere durable — the lib doesn't promise
77
+ persistence beyond the immediate capture flow.
78
+ - This applies to BOTH tap-photo and panorama, so call-sites can
79
+ rely on a single naming + parent-dir convention regardless of
80
+ capture type.
81
+ - **`RNImageStitcherFileUtils` native module** (internal) —
82
+ small Swift + Kotlin bridge exposing `moveFile(from, to)` and
83
+ `defaultCaptureDir()`. Used by the lib's own JS layer to relocate
84
+ vision-camera's auto-named output into the canonical default dir
85
+ / `outputDir` without forcing a peer dep on `expo-file-system`
86
+ for every consumer. Not re-exported from `src/index.ts`.
87
+
88
+ ### Fixed
89
+
90
+ - **Android `cv::imwrite` rejected `file://`-scheme output paths.**
91
+ `IncrementalStitcher.finalize` (Kotlin) was passing the host-
92
+ provided `outputPath` straight to `cv::imwrite` without
93
+ normalisation, so consumers using `expo-file-system`'s
94
+ `documentDirectory` (which always prefixes `file://`) hit
95
+ "Stitch failed: cv::imwrite returned false (code=101)" on every
96
+ panorama capture. iOS already stripped at the same boundary
97
+ (`IncrementalStitcher.swift:1215`); now Android does too via
98
+ `stripFileScheme()`, which already exists in the same file and
99
+ is used by `refinePanorama`. The fix has zero behaviour impact
100
+ on hosts that were already passing bare paths.
101
+ - **iOS modular-header build under `use_frameworks!`** — host apps
102
+ that opt into modular framework linkage (Expo + `use_frameworks!`,
103
+ RetaiLens-mobile is the immediate example) hit
104
+ ``'cstdint' file not found / could not build Objective-C module
105
+ 'RNImageStitcher'`` because CocoaPods defaulted EVERY header in
106
+ `source_files` (including the shared `cpp/*.hpp` C++ headers) to
107
+ public. The auto-generated `RNImageStitcher-umbrella.h` then
108
+ `#import`ed `keyframe_gate.hpp` / `stitcher.hpp` from a pure
109
+ Obj-C context and tripped on the C++ stdlib. Pin
110
+ `s.public_header_files = ['ios/Sources/**/*.h']` so the umbrella
111
+ exposes only the iOS-side Obj-C `.h` files; the `.mm` source files
112
+ still locate the C++ headers via `HEADER_SEARCH_PATHS` set in
113
+ `pod_target_xcconfig`, so behaviour is unchanged for non-modular
114
+ hosts. The umbrella now contains: `KeyframeGateBridge.h`,
115
+ `OpenCVIncrementalStitcher.h`, `OpenCVKeyframeCollector.h`,
116
+ `OpenCVSlitScanStitcher.h`, `OpenCVStitcher.h` — all Foundation /
117
+ CoreVideo-only declarations (the OpenCV C++ types stay inside the
118
+ `.mm` implementations).
119
+
19
120
  ## [0.1.1] — 2026-05-20
20
121
 
21
122
  ### Added
@@ -84,6 +185,16 @@ The following are intentionally internal so the public surface stays
84
185
  small. If you have a real use-case for any of these, please open an
85
186
  issue describing it.
86
187
 
188
+ > [!NOTE]
189
+ > **This list reflects the v0.1.0 surface as shipped.** Most of the
190
+ > entries below — the layer-2 hooks, views, UI components, and
191
+ > incremental-engine primitives — were subsequently promoted to public
192
+ > in [0.1.1]. See the 0.1.1 *Added* section for the current public
193
+ > surface; only a few items below remain internal in later releases
194
+ > (`CameraShutter`, `PanoramaConfirmModal`, `IncrementalStitcherView`,
195
+ > `stitchFrames`, `StitchNotImplementedError`, `runQualityCheck`,
196
+ > `normaliseOrientation`).
197
+
87
198
  - `useCapture`, `useDeviceOrientation` — internal hooks `<Camera>`
88
199
  composes; expose these only after we have a story for what their
89
200
  separate-from-`<Camera>` use-case looks like.
@@ -130,5 +241,8 @@ Native module names also changed:
130
241
  - iOS pod: `RetaiLensCaptureSDK` → `RNImageStitcher`
131
242
  - iOS xcframework: shipped as `opencv2.xcframework` (linked from `RNImageStitcher.podspec`)
132
243
 
133
- [Unreleased]: https://github.com/bhargavkanda/react-native-image-stitcher/compare/v0.1.0...HEAD
244
+ [Unreleased]: https://github.com/bhargavkanda/react-native-image-stitcher/compare/v0.1.3...HEAD
245
+ [0.1.3]: https://github.com/bhargavkanda/react-native-image-stitcher/compare/v0.1.2...v0.1.3
246
+ [0.1.2]: https://github.com/bhargavkanda/react-native-image-stitcher/compare/v0.1.1...v0.1.2
247
+ [0.1.1]: https://github.com/bhargavkanda/react-native-image-stitcher/compare/v0.1.0...v0.1.1
134
248
  [0.1.0]: https://github.com/bhargavkanda/react-native-image-stitcher/releases/tag/v0.1.0
package/README.md CHANGED
@@ -4,15 +4,6 @@
4
4
  One `<Camera>` component, both tap-to-photo and hold-to-pan modes, both
5
5
  AR-backed and IMU-fallback capture paths.
6
6
 
7
- > [!NOTE]
8
- > This package lives in the [RetaiLens monorepo](https://github.com/bhargav-kanda/RetaiLens)
9
- > under `retailens-capture-sdk/` during development. At publication
10
- > (see [`2026-05-15-react-native-image-stitcher-publication.md`](https://github.com/bhargav-kanda/RetaiLens/blob/main/docs/site-content/design/2026-05-15-react-native-image-stitcher-publication.md))
11
- > the public subset is `git subtree split` extracted to a standalone
12
- > repo at `github.com/bhargavkanda/react-native-image-stitcher` and
13
- > published to npm. This README describes the **public lib** as it
14
- > will look post-extraction.
15
-
16
7
  ## What it does
17
8
 
18
9
  | Feature | Behaviour |
@@ -37,10 +37,16 @@ Pod::Spec.new do |s|
37
37
  # (cpp/) that both iOS and Android compile from a single source.
38
38
  s.source_files = ['ios/Sources/**/*.{swift,h,m,mm}',
39
39
  'cpp/**/*.{h,hpp,cpp}']
40
- # public_header_files intentionally omitted React Native's
41
- # @objc(...) dispatch doesn't need umbrella headers, and exposing
42
- # all OpenCV*.h headers to consumers locks us into supporting
43
- # internal Obj-C++ classes as public API. See CHANGELOG v0.1.0.
40
+ # Restrict the umbrella header to ONLY the iOS-side Obj-C `.h`
41
+ # files. Without this, CocoaPods defaults every header in
42
+ # `source_files` (including the C++ `.hpp` files under cpp/) to
43
+ # public which is fine for non-modular builds, but breaks any
44
+ # host app using `use_frameworks!` (as RetaiLens does): the
45
+ # umbrella module is compiled in pure Obj-C context and chokes on
46
+ # `#import "keyframe_gate.hpp"` with `'cstdint' file not found`.
47
+ # The .mm files still find the C++ headers via HEADER_SEARCH_PATHS
48
+ # below; they just don't get pulled into the umbrella.
49
+ s.public_header_files = ['ios/Sources/**/*.h']
44
50
 
45
51
  # Frameworks shipped with iOS itself — no binary cost.
46
52
  s.frameworks = ['Accelerate', 'CoreImage', 'UIKit', 'ARKit']
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // keyframe_gate_jni.cpp — JNI bindings exposing the shared C++
4
4
  // retailens::KeyframeGate (in ../../../../cpp/) to the Kotlin side
5
- // (com.retailens.capturesdk.KeyframeGate).
5
+ // (io.imagestitcher.rn.KeyframeGate).
6
6
  //
7
7
  // Architecture parity with iOS:
8
8
  // iOS uses an Obj-C++ bridge (KeyframeGateBridge.mm) to wrap the
@@ -50,7 +50,7 @@ class BatchStitcher(reactContext: ReactApplicationContext)
50
50
  * JNI bridge to our custom-built OpenCV stitcher. Mirrors iOS'
51
51
  * OpenCVStitcher.stitchFramePaths so the batch-keyframe flow has
52
52
  * parity across platforms. Implementation:
53
- * retailens-capture-sdk/android/src/main/cpp/image_stitcher_jni.cpp
53
+ * react-native-image-stitcher/android/src/main/cpp/image_stitcher_jni.cpp
54
54
  *
55
55
  * @param framePaths input JPEG paths in capture order (≥2 required)
56
56
  * @param outputPath destination JPEG path
@@ -0,0 +1,79 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ //
3
+ // FileBridge.kt
4
+ //
5
+ // Android side of the same small file-utility module exposed on
6
+ // iOS by FileBridge.swift / FileBridge.m. See the Swift file for
7
+ // the architectural rationale on why this exists.
8
+
9
+ package io.imagestitcher.rn
10
+
11
+ import com.facebook.react.bridge.Promise
12
+ import com.facebook.react.bridge.ReactApplicationContext
13
+ import com.facebook.react.bridge.ReactContextBaseJavaModule
14
+ import com.facebook.react.bridge.ReactMethod
15
+ import java.io.File
16
+
17
+ class FileBridge(reactContext: ReactApplicationContext)
18
+ : ReactContextBaseJavaModule(reactContext) {
19
+
20
+ override fun getName(): String = "RNImageStitcherFileUtils"
21
+
22
+ /**
23
+ * Move (with copy+delete fallback) a file from `from` to `to`.
24
+ * Both paths can be bare or `file://`-prefixed. Creates the
25
+ * destination's parent dir tree on demand. Resolves to the bare
26
+ * destination path.
27
+ */
28
+ @ReactMethod
29
+ fun moveFile(from: String, to: String, promise: Promise) {
30
+ try {
31
+ val cleanFrom = if (from.startsWith("file://")) from.substring(7) else from
32
+ val cleanTo = if (to.startsWith("file://")) to.substring(7) else to
33
+ val src = File(cleanFrom)
34
+ val dst = File(cleanTo)
35
+ dst.parentFile?.mkdirs()
36
+ if (dst.exists()) {
37
+ dst.delete()
38
+ }
39
+ // Cheap rename first (same volume); copyTo + delete fallback
40
+ // handles the theoretical cross-volume case.
41
+ if (!src.renameTo(dst)) {
42
+ src.copyTo(dst, overwrite = true)
43
+ src.delete()
44
+ }
45
+ promise.resolve(cleanTo)
46
+ } catch (e: Exception) {
47
+ promise.reject(
48
+ "FILE_MOVE_FAILED",
49
+ "Failed to move $from → $to: ${e.message}",
50
+ e,
51
+ )
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Resolve the lib's canonical default capture dir, creating it on
57
+ * demand. Returns a bare absolute path.
58
+ *
59
+ * Lives under `context.cacheDir` because that's the Android
60
+ * equivalent of iOS's `NSCachesDirectory`: persists across
61
+ * restarts, evictable by the OS under pressure, not backed up.
62
+ */
63
+ @ReactMethod
64
+ fun defaultCaptureDir(promise: Promise) {
65
+ try {
66
+ val dir = File(reactApplicationContext.cacheDir, "react-native-image-stitcher")
67
+ if (!dir.exists()) {
68
+ dir.mkdirs()
69
+ }
70
+ promise.resolve(dir.absolutePath)
71
+ } catch (e: Exception) {
72
+ promise.reject(
73
+ "DIR_CREATE_FAILED",
74
+ "Failed to create canonical capture dir: ${e.message}",
75
+ e,
76
+ )
77
+ }
78
+ }
79
+ }
@@ -193,7 +193,7 @@ class IncrementalStitcher(
193
193
  /// (handleBatchKeyframeFrame above) with the same pose-driven
194
194
  /// 40%-new-content algorithm iOS has used since the V16 ship.
195
195
  /// Both platforms call into retailens::KeyframeGate (in
196
- /// retailens-capture-sdk/cpp/keyframe_gate.cpp) — see that file
196
+ /// react-native-image-stitcher/cpp/keyframe_gate.cpp) — see that file
197
197
  /// for the algorithm.
198
198
  ///
199
199
  /// Lifetime: owned for the life of the module. Closed in
@@ -731,7 +731,15 @@ class IncrementalStitcher(
731
731
  val outputPath = if (outputPathOpt.isEmpty()) {
732
732
  File(reactContext.cacheDir, "RNImageStitcherIncremental-${System.nanoTime()}.jpg").absolutePath
733
733
  } else {
734
- outputPathOpt
734
+ // Strip the `file://` scheme — hosts commonly pass paths
735
+ // sourced from `expo-file-system`'s `documentDirectory`,
736
+ // which always prefixes `file://`. cv::imwrite (used
737
+ // downstream by the batch-keyframe + hybrid + slit-scan
738
+ // engines) silently returns false on URI-scheme paths,
739
+ // surfacing as "Stitch failed: cv::imwrite returned
740
+ // false". iOS already strips at the same boundary —
741
+ // see IncrementalStitcher.swift:1215.
742
+ stripFileScheme(outputPathOpt)
735
743
  }
736
744
  val quality = options.getIntOrDefault("quality", 90)
737
745
  // 2026-05-18 (iOS cross-orientation fix; symmetric on Android) —
@@ -1151,7 +1159,7 @@ class IncrementalStitcher(
1151
1159
  // iOS exposes these on the IncrementalStitcherBridge (NOT on the
1152
1160
  // ARSession module) so the JS code calls
1153
1161
  // getIncrementalNativeModule().getARPlaneStatus()
1154
- // (see retailens-capture-sdk/src/stitching/incremental.ts:535).
1162
+ // (see react-native-image-stitcher/src/stitching/incremental.ts:535).
1155
1163
  // Both methods delegate to the AR session singleton — same pattern
1156
1164
  // as iOS' IncrementalStitcherBridge.swift, where the bridge holds
1157
1165
  // the RN @objc surface and the singleton holds the AR algorithm.
@@ -3,7 +3,7 @@ package io.imagestitcher.rn
3
3
 
4
4
  /**
5
5
  * Kotlin facade over the shared C++ KeyframeGate (in
6
- * retailens-capture-sdk/cpp/keyframe_gate.{hpp,cpp}).
6
+ * react-native-image-stitcher/cpp/keyframe_gate.{hpp,cpp}).
7
7
  *
8
8
  * Architecture parity with iOS:
9
9
  * iOS uses an Obj-C++ bridge (KeyframeGateBridge.mm) to wrap the
@@ -29,6 +29,7 @@ class RNImageStitcherPackage : ReactPackage {
29
29
  BatchStitcher(reactContext),
30
30
  RNSARSession(reactContext),
31
31
  IncrementalStitcher(reactContext),
32
+ FileBridge(reactContext),
32
33
  )
33
34
 
34
35
  override fun createViewManagers(
@@ -79,7 +79,7 @@ export type CameraCaptureResult = {
79
79
  * Errors surfaced via `onError`. Classified codes so consumers can
80
80
  * branch on the kind of failure (toast vs retry vs report).
81
81
  */
82
- export type CameraErrorCode = 'CAMERA_PERMISSION_DENIED' | 'CAMERA_DEVICE_UNAVAILABLE' | 'PHOTO_CAPTURE_FAILED' | 'PANORAMA_START_FAILED' | 'PANORAMA_FINALIZE_FAILED' | 'STITCH_NEED_MORE_IMGS' | 'STITCH_HOMOGRAPHY_FAIL' | 'STITCH_CAMERA_PARAMS_FAIL' | 'STITCH_OOM' | 'UNKNOWN';
82
+ export type CameraErrorCode = 'CAMERA_PERMISSION_DENIED' | 'CAMERA_DEVICE_UNAVAILABLE' | 'PHOTO_CAPTURE_FAILED' | 'PANORAMA_START_FAILED' | 'PANORAMA_FINALIZE_FAILED' | 'STITCH_NEED_MORE_IMGS' | 'STITCH_HOMOGRAPHY_FAIL' | 'STITCH_CAMERA_PARAMS_FAIL' | 'STITCH_OOM' | 'OUTPUT_WRITE_FAILED' | 'UNKNOWN';
83
83
  export declare class CameraError extends Error {
84
84
  readonly code: CameraErrorCode;
85
85
  readonly cause?: unknown;
@@ -121,6 +121,34 @@ export interface CameraProps {
121
121
  enablePanoramaMode?: boolean;
122
122
  showSettingsButton?: boolean;
123
123
  style?: StyleProp<ViewStyle>;
124
+ /**
125
+ * Optional destination directory for captures. When set, the lib
126
+ * lands tap-photos at `${outputDir}/photo-${ts}.jpg` and panoramas
127
+ * at `${outputDir}/panorama-${ts}.jpg` and the returned uri points
128
+ * at the persisted file (vs. vision-camera's tmp dir, which is
129
+ * what you get when this prop is omitted).
130
+ *
131
+ * The host is solely responsible for:
132
+ * - Choosing a writable directory (the lib does NOT pick this for
133
+ * you on either platform — particularly relevant on Android,
134
+ * where scoped-storage rules differ between app-private storage
135
+ * and user-visible Documents/Pictures dirs).
136
+ * - Ensuring the directory exists. The lib will create it if it
137
+ * doesn't, but only inside paths the OS lets it write to.
138
+ * - Making the path user-visible if that matters (`UIFileSharingEnabled`
139
+ * on iOS for `FileSystem.documentDirectory`; MediaStore /
140
+ * `Documents/...` on Android — see your platform's docs).
141
+ *
142
+ * On disk failure the capture promise rejects via `onError` with
143
+ * `CameraError('OUTPUT_WRITE_FAILED', ...)`. No silent fallback to
144
+ * tmp — that hides bugs.
145
+ *
146
+ * Requires `expo-file-system` (declared as an OPTIONAL peer dep;
147
+ * only needed when this prop is set).
148
+ *
149
+ * Format: bare path or `file://` URI. Both accepted.
150
+ */
151
+ outputDir?: string;
124
152
  onCapture?: (result: CameraCaptureResult) => void;
125
153
  onCaptureSourceChange?: (source: CaptureSource) => void;
126
154
  onLensChange?: (lens: CameraLens) => void;
@@ -92,6 +92,8 @@ const incremental_1 = require("../stitching/incremental");
92
92
  const useIncrementalJSDriver_1 = require("../stitching/useIncrementalJSDriver");
93
93
  const useIncrementalStitcher_1 = require("../stitching/useIncrementalStitcher");
94
94
  const useIMUTranslationGate_1 = require("../sensors/useIMUTranslationGate");
95
+ const paths_1 = require("../utils/paths");
96
+ const files_1 = require("../utils/files");
95
97
  class CameraError extends Error {
96
98
  constructor(code, message, cause) {
97
99
  super(message);
@@ -249,35 +251,19 @@ function buildInitialSettings(props) {
249
251
  PanoramaSettingsModal_1.DEFAULT_PANORAMA_SETTINGS.keyframeOverlapThreshold,
250
252
  };
251
253
  }
252
- /**
253
- * Normalise a native-side file path into the `file://...` URI form
254
- * that React Native's `<Image>` requires on Android. iOS is lenient,
255
- * but Android rejects bare `/data/...` paths and renders a blank
256
- * Image with no error in the JS layer.
257
- *
258
- * Native code in this lib emits paths in two flavours:
259
- * - useCapture.compressedUri already includes `file://` (it's
260
- * normalised in `makeCaptureResult`).
261
- * - ARCameraView.takePhoto, IncrementalStitcher.finalize, and the
262
- * `batchKeyframeThumbnailPath` from `IncrementalStateUpdate` all
263
- * return bare paths. Those are the cases this helper handles.
264
- *
265
- * Already-prefixed inputs are passed through unchanged, so it's safe
266
- * to call defensively at every public-API boundary.
267
- */
268
- function ensureFileUri(path) {
269
- if (!path)
270
- return '';
271
- if (path.startsWith('file://') || path.startsWith('content://') || path.startsWith('http')) {
272
- return path;
273
- }
274
- return `file://${path}`;
275
- }
254
+ // `toFileUri` (used to be an inline `toFileUri` here) lives in
255
+ // `../utils/paths.ts` so every call-site in this lib funnels through
256
+ // one canonical implementation. Native bridges return paths in
257
+ // mixed shapes useCapture.compressedUri already has `file://`,
258
+ // while ARCameraView.takePhoto + IncrementalStitcher.finalize +
259
+ // `batchKeyframeThumbnailPath` events all return bare paths — and we
260
+ // normalise to the URI form on the way out to JS consumers (Android
261
+ // `<Image>` requires the scheme; iOS is lenient).
276
262
  /**
277
263
  * The public `<Camera>` component.
278
264
  */
279
265
  function Camera(props) {
280
- const { defaultCaptureSource = 'ar', defaultLens = '1x', enablePhotoMode = true, enablePanoramaMode = true, showSettingsButton = false, style, onCapture, onCaptureSourceChange, onLensChange, onFramesDropped, onError, } = props;
266
+ const { defaultCaptureSource = 'ar', defaultLens = '1x', enablePhotoMode = true, enablePanoramaMode = true, showSettingsButton = false, style, outputDir, onCapture, onCaptureSourceChange, onLensChange, onFramesDropped, onError, } = props;
281
267
  const insets = (0, react_native_safe_area_context_1.useSafeAreaInsets)();
282
268
  // ── State ───────────────────────────────────────────────────────
283
269
  const [arPreference, setArPreference] = (0, react_1.useState)(defaultCaptureSource === 'ar');
@@ -431,7 +417,7 @@ function Camera(props) {
431
417
  // De-dupe — same path may emit on subsequent ticks.
432
418
  // Normalise to `file://...` so Android <Image> in the band
433
419
  // overlay can actually render the thumbnail.
434
- const path = ensureFileUri(state.batchKeyframeThumbnailPath);
420
+ const path = (0, paths_1.toFileUri)(state.batchKeyframeThumbnailPath);
435
421
  if (prev.includes(path))
436
422
  return prev;
437
423
  return [...prev, path];
@@ -454,12 +440,29 @@ function Camera(props) {
454
440
  let uri;
455
441
  let width;
456
442
  let height;
443
+ // Compose the destination path BEFORE the capture so both the
444
+ // AR and non-AR branches land at the same predictable location.
445
+ // If `outputDir` is set, the lib lands the file at a host-
446
+ // controlled path; otherwise, in the lib's canonical capture
447
+ // dir (`<cache>/react-native-image-stitcher/photo-<ms>.jpg`).
448
+ const photoOutputPath = outputDir
449
+ ? `${(0, paths_1.toBareFilePath)(outputDir).replace(/\/$/, '')}/${(0, files_1.defaultPhotoFilename)()}`
450
+ : `${await (0, files_1.getDefaultCaptureDir)()}/${(0, files_1.defaultPhotoFilename)()}`;
457
451
  if (isAR && arViewRef.current) {
452
+ // ARCameraView writes to its own tmp location; relocate to
453
+ // photoOutputPath via the native FileBridge so both branches
454
+ // return paths under the same dir.
458
455
  const photo = await arViewRef.current.takePhoto({ quality: 90 });
459
- // Native side returns a bare `/data/.../foo.jpg` path. Android
460
- // <Image> needs the `file://` scheme to render it; iOS is OK
461
- // either way.
462
- uri = ensureFileUri(photo.path);
456
+ try {
457
+ await (0, files_1.moveFile)(photo.path, photoOutputPath);
458
+ }
459
+ catch (moveErr) {
460
+ throw new CameraError('OUTPUT_WRITE_FAILED', `Failed to move AR photo to ${photoOutputPath}. The destination `
461
+ + 'directory must be writable.', moveErr);
462
+ }
463
+ // Android <Image> needs the `file://` scheme to render the
464
+ // returned uri; iOS is OK either way. Normalise once here.
465
+ uri = (0, paths_1.toFileUri)(photoOutputPath);
463
466
  width = photo.width;
464
467
  height = photo.height;
465
468
  }
@@ -470,12 +473,11 @@ function Camera(props) {
470
473
  // useCapture.takePhoto wraps the cameraRef internally;
471
474
  // attach via assignment so the hook's ref points at our
472
475
  // local ref. This works because RefObject is just { current }.
473
- // Effect: capture.takePhoto() resolves with the SDK's
474
- // CaptureResult (with compressedUri / width / height).
475
- // We adapt to the public CameraCaptureResult shape.
476
476
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
477
477
  capture.cameraRef.current = visionCameraRef.current;
478
- const result = await capture.takePhoto();
478
+ // useCapture handles the move internally; the returned
479
+ // `compressedUri` already points at `photoOutputPath`.
480
+ const result = await capture.takePhoto({ outputPath: photoOutputPath });
479
481
  uri = result.compressedUri;
480
482
  width = result.width;
481
483
  height = result.height;
@@ -488,7 +490,7 @@ function Camera(props) {
488
490
  : new CameraError('PHOTO_CAPTURE_FAILED', err instanceof Error ? err.message : String(err), err);
489
491
  onError?.(e);
490
492
  }
491
- }, [enablePhotoMode, isAR, capture, onCapture, onError]);
493
+ }, [enablePhotoMode, isAR, capture, outputDir, onCapture, onError]);
492
494
  const handleHoldStart = (0, react_1.useCallback)(async () => {
493
495
  if (!enablePanoramaMode)
494
496
  return;
@@ -560,7 +562,15 @@ function Camera(props) {
560
562
  // No-op in AR mode where jsDriver was never started.
561
563
  jsDriver.stop();
562
564
  try {
563
- const result = await incremental.finalize(undefined, 90, deviceOrientation);
565
+ // Compose the panorama output path: host-controlled if
566
+ // `outputDir` is set, else the lib's canonical capture dir
567
+ // (`<cache>/react-native-image-stitcher/panorama-<ms>.jpg`).
568
+ // `incremental.finalize` writes the stitched JPEG straight to
569
+ // this path natively (no JS-side move needed for panoramas).
570
+ const panoOutputPath = outputDir
571
+ ? `${(0, paths_1.toBareFilePath)(outputDir).replace(/\/$/, '')}/${(0, files_1.defaultPanoramaFilename)()}`
572
+ : `${await (0, files_1.getDefaultCaptureDir)()}/${(0, files_1.defaultPanoramaFilename)()}`;
573
+ const result = await incremental.finalize(panoOutputPath, 90, deviceOrientation);
564
574
  if (typeof result.framesRequested === 'number'
565
575
  && typeof result.framesIncluded === 'number'
566
576
  && result.framesIncluded < result.framesRequested) {
@@ -573,7 +583,7 @@ function Camera(props) {
573
583
  type: 'panorama',
574
584
  // Native finalize() returns a bare `/data/.../foo.jpg` path;
575
585
  // normalise to `file://` for Android <Image>.
576
- uri: ensureFileUri(result.panoramaPath),
586
+ uri: (0, paths_1.toFileUri)(result.panoramaPath),
577
587
  width: result.width,
578
588
  height: result.height,
579
589
  framesRequested: result.framesRequested ?? -1,
@@ -69,6 +69,25 @@ export interface UseCaptureOptions {
69
69
  */
70
70
  preferredPhysicalDevice?: PhysicalCameraDeviceType;
71
71
  }
72
+ /**
73
+ * Per-call options for `takePhoto`. Separate from `UseCaptureOptions`
74
+ * (the hook-level config) so callers can vary the destination
75
+ * filename per capture without re-creating the hook.
76
+ */
77
+ export interface TakePhotoCallOptions {
78
+ /**
79
+ * Move the captured JPEG to this fully-resolved path after EXIF
80
+ * orientation correction. Requires `expo-file-system` in the
81
+ * host (declared as an OPTIONAL peer — only needed when
82
+ * `outputPath` is set). Host is responsible for the destination
83
+ * directory's existence and writability; lib rejects loudly on
84
+ * disk failure rather than silently falling back to a tmp path.
85
+ *
86
+ * Format: bare path (e.g. `/data/.../foo.jpg`) or `file://`-prefixed
87
+ * URI — both accepted; lib normalises internally.
88
+ */
89
+ outputPath?: string;
90
+ }
72
91
  /**
73
92
  * Hook output. Intentionally flat so destructuring a subset is
74
93
  * cheap and the API doesn't force callers to drill into nested
@@ -92,8 +111,19 @@ export interface UseCaptureReturn {
92
111
  * Take a photo. Single-flight: parallel calls return the in-flight
93
112
  * promise. Returns a CaptureResult (with an optional QualityReport
94
113
  * when ``enableQualityChecks`` is on).
114
+ *
115
+ * `outputPath` (optional): a fully-resolved destination path. When
116
+ * set, the lib moves the captured JPEG to that path after EXIF
117
+ * orientation correction, and the returned `compressedUri` points
118
+ * at the moved file. The host is responsible for ensuring the
119
+ * destination directory exists and is writable; on disk failure,
120
+ * the promise rejects with an error referencing `outputPath`.
121
+ *
122
+ * Requires `expo-file-system` to be installed in the host app
123
+ * (declared as an OPTIONAL peer dep — consumers that don't pass
124
+ * `outputPath` aren't required to have it).
95
125
  */
96
- takePhoto: () => Promise<CaptureResult>;
126
+ takePhoto: (options?: TakePhotoCallOptions) => Promise<CaptureResult>;
97
127
  /**
98
128
  * 2026-05-14 — physical lens types available on the chosen
99
129
  * `cameraPosition`. Computed once at the first vision-camera
@@ -31,6 +31,8 @@ const react_1 = require("react");
31
31
  const react_native_vision_camera_1 = require("react-native-vision-camera");
32
32
  const runQualityCheck_1 = require("../quality/runQualityCheck");
33
33
  const normaliseOrientation_1 = require("../quality/normaliseOrientation");
34
+ const paths_1 = require("../utils/paths");
35
+ const files_1 = require("../utils/files");
34
36
  function makeCaptureResult(photo, qualityReport) {
35
37
  const capturedAt = new Date().toISOString();
36
38
  return {
@@ -101,7 +103,7 @@ function useCapture(options = {}) {
101
103
  const toggleFlash = (0, react_1.useCallback)(() => {
102
104
  setFlash((prev) => (prev === 'off' ? 'on' : 'off'));
103
105
  }, []);
104
- const takePhoto = (0, react_1.useCallback)(async () => {
106
+ const takePhoto = (0, react_1.useCallback)(async (callOptions) => {
105
107
  if (inFlightRef.current) {
106
108
  return inFlightRef.current;
107
109
  }
@@ -126,11 +128,30 @@ function useCapture(options = {}) {
126
128
  width: photo.width,
127
129
  height: photo.height,
128
130
  });
129
- const orientedPhoto = {
131
+ let orientedPhoto = {
130
132
  ...photo,
131
133
  width: normalised.width || photo.width,
132
134
  height: normalised.height || photo.height,
133
135
  };
136
+ // Move the orientation-corrected file to its final location.
137
+ // If the caller passed `outputPath`, use that. Otherwise, the
138
+ // lib publishes captures into its canonical default dir so
139
+ // returned paths are predictable across consumers (vs.
140
+ // vision-camera's auto-generated UUID-named tmp file). The
141
+ // move is performed via the `RNImageStitcherFileUtils` native
142
+ // bridge — no peer-dep on `expo-file-system` etc.
143
+ try {
144
+ const dstPath = callOptions?.outputPath
145
+ ? (0, paths_1.toBareFilePath)(callOptions.outputPath)
146
+ : `${await (0, files_1.getDefaultCaptureDir)()}/${(0, files_1.defaultPhotoFilename)()}`;
147
+ await (0, files_1.moveFile)(orientedPhoto.path, dstPath);
148
+ orientedPhoto = { ...orientedPhoto, path: dstPath };
149
+ }
150
+ catch (e) {
151
+ throw new Error('useCapture.takePhoto: failed to move captured photo to its '
152
+ + `destination${callOptions?.outputPath ? ` (${callOptions.outputPath})` : ' (default capture dir)'}. `
153
+ + `Underlying: ${e instanceof Error ? e.message : String(e)}`);
154
+ }
134
155
  let report;
135
156
  if (enableQualityChecks && qualityThresholds) {
136
157
  report = await (0, runQualityCheck_1.runQualityCheck)(orientedPhoto.path, qualityThresholds);
package/dist/index.d.ts CHANGED
@@ -44,6 +44,7 @@ export { PanoramaSettingsModal, DEFAULT_PANORAMA_SETTINGS, } from './camera/Pano
44
44
  export type { PanoramaSettings } from './camera/PanoramaSettingsModal';
45
45
  export { ViewportCropOverlay } from './camera/ViewportCropOverlay';
46
46
  export { useCapture } from './camera/useCapture';
47
+ export type { TakePhotoCallOptions } from './camera/useCapture';
47
48
  export { useVideoCapture } from './camera/useVideoCapture';
48
49
  export { useDeviceOrientation } from './camera/useDeviceOrientation';
49
50
  export { IncrementalOutcome, incrementalStitcherIsAvailable, subscribeIncrementalState, getIncrementalNativeModule, cleanupOldKeyframes, } from './stitching/incremental';
@@ -5,7 +5,7 @@
5
5
  * - iOS: Swift native module that vendors upstream OpenCV's iOS
6
6
  * framework and calls `cv::Stitcher::SCANS` mode (designed for
7
7
  * translational shelf captures). Lives in
8
- * `retailens-capture-sdk/ios/Sources/RNImageStitcher/`.
8
+ * `react-native-image-stitcher/ios/Sources/RNImageStitcher/`.
9
9
  * - Android: deferred to Phase 3 — same OpenCV surface, different
10
10
  * build (NDK + Gradle). Until that lands, Android calls hit the
11
11
  * `StitchNotImplementedError` path below.
@@ -7,7 +7,7 @@
7
7
  * - iOS: Swift native module that vendors upstream OpenCV's iOS
8
8
  * framework and calls `cv::Stitcher::SCANS` mode (designed for
9
9
  * translational shelf captures). Lives in
10
- * `retailens-capture-sdk/ios/Sources/RNImageStitcher/`.
10
+ * `react-native-image-stitcher/ios/Sources/RNImageStitcher/`.
11
11
  * - Android: deferred to Phase 3 — same OpenCV surface, different
12
12
  * build (NDK + Gradle). Until that lands, Android calls hit the
13
13
  * `StitchNotImplementedError` path below.