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.
- package/CHANGELOG.md +115 -1
- package/README.md +0 -9
- package/RNImageStitcher.podspec +10 -4
- package/android/src/main/cpp/keyframe_gate_jni.cpp +1 -1
- package/android/src/main/java/io/imagestitcher/rn/BatchStitcher.kt +1 -1
- package/android/src/main/java/io/imagestitcher/rn/FileBridge.kt +79 -0
- package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +11 -3
- package/android/src/main/java/io/imagestitcher/rn/KeyframeGate.kt +1 -1
- package/android/src/main/java/io/imagestitcher/rn/RNImageStitcherPackage.kt +1 -0
- package/dist/camera/Camera.d.ts +29 -1
- package/dist/camera/Camera.js +47 -37
- package/dist/camera/useCapture.d.ts +31 -1
- package/dist/camera/useCapture.js +23 -2
- package/dist/index.d.ts +1 -0
- package/dist/stitching/stitchFrames.d.ts +1 -1
- package/dist/stitching/stitchFrames.js +1 -1
- package/dist/utils/files.d.ts +44 -0
- package/dist/utils/files.js +84 -0
- package/dist/utils/paths.d.ts +30 -0
- package/dist/utils/paths.js +48 -0
- package/ios/Package.swift +1 -1
- package/ios/Sources/RNImageStitcher/FileBridge.m +20 -0
- package/ios/Sources/RNImageStitcher/FileBridge.swift +110 -0
- package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +4 -4
- package/ios/Sources/RNImageStitcher/KeyframeGate.swift +1 -1
- package/ios/Sources/RNImageStitcher/KeyframeGateBridge.h +1 -1
- package/ios/Sources/RNImageStitcher/KeyframeGateBridge.mm +1 -1
- package/ios/Sources/RNImageStitcher/RNSARSession.swift +1 -1
- package/package.json +1 -1
- package/src/camera/Camera.tsx +85 -35
- package/src/camera/useCapture.ts +62 -3
- package/src/index.ts +1 -0
- package/src/stitching/stitchFrames.ts +1 -1
- package/src/utils/files.ts +97 -0
- 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.
|
|
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 |
|
package/RNImageStitcher.podspec
CHANGED
|
@@ -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
|
-
#
|
|
41
|
-
#
|
|
42
|
-
#
|
|
43
|
-
#
|
|
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
|
-
// (
|
|
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
|
-
*
|
|
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
|
-
///
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
package/dist/camera/Camera.d.ts
CHANGED
|
@@ -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;
|
package/dist/camera/Camera.js
CHANGED
|
@@ -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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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 =
|
|
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
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
* `
|
|
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
|
-
* `
|
|
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.
|