react-native-image-stitcher 0.1.1 → 0.1.2
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 +76 -0
- package/RNImageStitcher.podspec +10 -4
- package/android/src/main/java/io/imagestitcher/rn/FileBridge.kt +79 -0
- package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +9 -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/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/Sources/RNImageStitcher/FileBridge.m +20 -0
- package/ios/Sources/RNImageStitcher/FileBridge.swift +110 -0
- 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/utils/files.ts +97 -0
- package/src/utils/paths.ts +42 -0
package/CHANGELOG.md
CHANGED
|
@@ -16,6 +16,82 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
16
16
|
|
|
17
17
|
## [Unreleased]
|
|
18
18
|
|
|
19
|
+
## [0.1.2] — 2026-05-20
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- **`outputDir` prop on `<Camera>`** + **`outputPath` per-call
|
|
24
|
+
option on `useCapture.takePhoto`** — captures (both tap-photos
|
|
25
|
+
and hold-panoramas) can now land at a host-controlled file
|
|
26
|
+
location instead of vision-camera's tmp dir. Filename is
|
|
27
|
+
composed internally as `${outputDir}/photo-${ts}.jpg` /
|
|
28
|
+
`${outputDir}/panorama-${ts}.jpg` for `<Camera>`; per-call
|
|
29
|
+
`outputPath` on `useCapture` lets layer-2 hosts compose their
|
|
30
|
+
own filenames.
|
|
31
|
+
- On disk failure the capture rejects with
|
|
32
|
+
`CameraError('OUTPUT_WRITE_FAILED', ...)`. **No silent
|
|
33
|
+
fallback** to a different path — that hides bugs.
|
|
34
|
+
- Host owns *picking* the path. The lib treats the value as an
|
|
35
|
+
opaque writable filesystem path; it does not know about iOS
|
|
36
|
+
`UIFileSharingEnabled`, Android MediaStore, SAF, or any other
|
|
37
|
+
platform-specific shared-storage mechanism. That's the host's
|
|
38
|
+
domain.
|
|
39
|
+
- **No peer deps required** — the move is handled by a small
|
|
40
|
+
native bridge (`RNImageStitcherFileUtils`) that ships with
|
|
41
|
+
the lib.
|
|
42
|
+
- **Canonical default capture directory** — when neither
|
|
43
|
+
`outputDir` nor `outputPath` is set, the lib now writes captures
|
|
44
|
+
to a predictable per-platform location instead of vision-camera's
|
|
45
|
+
auto-generated tmp paths:
|
|
46
|
+
- iOS: `<NSCachesDirectory>/react-native-image-stitcher/photo-<ms>.jpg`
|
|
47
|
+
(and `panorama-<ms>.jpg`).
|
|
48
|
+
- Android: `<context.cacheDir>/react-native-image-stitcher/...`.
|
|
49
|
+
- Both are app-private, evictable by the OS under storage
|
|
50
|
+
pressure, not backed up. Captures live here until the host
|
|
51
|
+
moves them somewhere durable — the lib doesn't promise
|
|
52
|
+
persistence beyond the immediate capture flow.
|
|
53
|
+
- This applies to BOTH tap-photo and panorama, so call-sites can
|
|
54
|
+
rely on a single naming + parent-dir convention regardless of
|
|
55
|
+
capture type.
|
|
56
|
+
- **`RNImageStitcherFileUtils` native module** (internal) —
|
|
57
|
+
small Swift + Kotlin bridge exposing `moveFile(from, to)` and
|
|
58
|
+
`defaultCaptureDir()`. Used by the lib's own JS layer to relocate
|
|
59
|
+
vision-camera's auto-named output into the canonical default dir
|
|
60
|
+
/ `outputDir` without forcing a peer dep on `expo-file-system`
|
|
61
|
+
for every consumer. Not re-exported from `src/index.ts`.
|
|
62
|
+
|
|
63
|
+
### Fixed
|
|
64
|
+
|
|
65
|
+
- **Android `cv::imwrite` rejected `file://`-scheme output paths.**
|
|
66
|
+
`IncrementalStitcher.finalize` (Kotlin) was passing the host-
|
|
67
|
+
provided `outputPath` straight to `cv::imwrite` without
|
|
68
|
+
normalisation, so consumers using `expo-file-system`'s
|
|
69
|
+
`documentDirectory` (which always prefixes `file://`) hit
|
|
70
|
+
"Stitch failed: cv::imwrite returned false (code=101)" on every
|
|
71
|
+
panorama capture. iOS already stripped at the same boundary
|
|
72
|
+
(`IncrementalStitcher.swift:1215`); now Android does too via
|
|
73
|
+
`stripFileScheme()`, which already exists in the same file and
|
|
74
|
+
is used by `refinePanorama`. The fix has zero behaviour impact
|
|
75
|
+
on hosts that were already passing bare paths.
|
|
76
|
+
- **iOS modular-header build under `use_frameworks!`** — host apps
|
|
77
|
+
that opt into modular framework linkage (Expo + `use_frameworks!`,
|
|
78
|
+
RetaiLens-mobile is the immediate example) hit
|
|
79
|
+
``'cstdint' file not found / could not build Objective-C module
|
|
80
|
+
'RNImageStitcher'`` because CocoaPods defaulted EVERY header in
|
|
81
|
+
`source_files` (including the shared `cpp/*.hpp` C++ headers) to
|
|
82
|
+
public. The auto-generated `RNImageStitcher-umbrella.h` then
|
|
83
|
+
`#import`ed `keyframe_gate.hpp` / `stitcher.hpp` from a pure
|
|
84
|
+
Obj-C context and tripped on the C++ stdlib. Pin
|
|
85
|
+
`s.public_header_files = ['ios/Sources/**/*.h']` so the umbrella
|
|
86
|
+
exposes only the iOS-side Obj-C `.h` files; the `.mm` source files
|
|
87
|
+
still locate the C++ headers via `HEADER_SEARCH_PATHS` set in
|
|
88
|
+
`pod_target_xcconfig`, so behaviour is unchanged for non-modular
|
|
89
|
+
hosts. The umbrella now contains: `KeyframeGateBridge.h`,
|
|
90
|
+
`OpenCVIncrementalStitcher.h`, `OpenCVKeyframeCollector.h`,
|
|
91
|
+
`OpenCVSlitScanStitcher.h`, `OpenCVStitcher.h` — all Foundation /
|
|
92
|
+
CoreVideo-only declarations (the OpenCV C++ types stay inside the
|
|
93
|
+
`.mm` implementations).
|
|
94
|
+
|
|
19
95
|
## [0.1.1] — 2026-05-20
|
|
20
96
|
|
|
21
97
|
### Added
|
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']
|
|
@@ -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
|
+
}
|
|
@@ -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) —
|
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';
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin JS wrapper around the `RNImageStitcherFileUtils` native
|
|
3
|
+
* module (defined in `ios/Sources/RNImageStitcher/FileBridge.{swift,m}`
|
|
4
|
+
* and `android/src/main/.../FileBridge.kt`). Internal — not
|
|
5
|
+
* re-exported from `src/index.ts`.
|
|
6
|
+
*
|
|
7
|
+
* Two operations:
|
|
8
|
+
* - `moveFile(from, to)` — move a file, creating the destination's
|
|
9
|
+
* parent directory tree on demand. Used to relocate
|
|
10
|
+
* vision-camera's auto-named tmp output into the lib's canonical
|
|
11
|
+
* capture dir.
|
|
12
|
+
* - `getDefaultCaptureDir()` — resolve (and create on first call)
|
|
13
|
+
* the canonical default capture directory:
|
|
14
|
+
*
|
|
15
|
+
* iOS: `<NSCachesDirectory>/react-native-image-stitcher/`
|
|
16
|
+
* Android: `<context.cacheDir>/react-native-image-stitcher/`
|
|
17
|
+
*
|
|
18
|
+
* Predictable, evictable-by-OS, NOT backed up. Captures live
|
|
19
|
+
* here until the host moves them somewhere durable (which is
|
|
20
|
+
* intentional — the lib doesn't promise persistence beyond the
|
|
21
|
+
* immediate capture flow).
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* Move a file via the native bridge. Both paths accepted in bare
|
|
25
|
+
* or `file://`-prefixed form. Resolves to the bare destination
|
|
26
|
+
* path on success. Throws on disk failure.
|
|
27
|
+
*/
|
|
28
|
+
export declare function moveFile(from: string, to: string): Promise<string>;
|
|
29
|
+
/**
|
|
30
|
+
* Resolve the canonical default capture directory. Lazy +
|
|
31
|
+
* memoised — the native side creates the dir on first call, JS
|
|
32
|
+
* caches the result for the rest of the app session.
|
|
33
|
+
*/
|
|
34
|
+
export declare function getDefaultCaptureDir(): Promise<string>;
|
|
35
|
+
/**
|
|
36
|
+
* Compose a default filename for a tap-photo capture, using a
|
|
37
|
+
* millisecond Unix timestamp for ordering. Pure helper; no I/O.
|
|
38
|
+
*/
|
|
39
|
+
export declare function defaultPhotoFilename(): string;
|
|
40
|
+
/**
|
|
41
|
+
* Same as `defaultPhotoFilename` but for panoramas.
|
|
42
|
+
*/
|
|
43
|
+
export declare function defaultPanoramaFilename(): string;
|
|
44
|
+
//# sourceMappingURL=files.d.ts.map
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
/**
|
|
4
|
+
* Thin JS wrapper around the `RNImageStitcherFileUtils` native
|
|
5
|
+
* module (defined in `ios/Sources/RNImageStitcher/FileBridge.{swift,m}`
|
|
6
|
+
* and `android/src/main/.../FileBridge.kt`). Internal — not
|
|
7
|
+
* re-exported from `src/index.ts`.
|
|
8
|
+
*
|
|
9
|
+
* Two operations:
|
|
10
|
+
* - `moveFile(from, to)` — move a file, creating the destination's
|
|
11
|
+
* parent directory tree on demand. Used to relocate
|
|
12
|
+
* vision-camera's auto-named tmp output into the lib's canonical
|
|
13
|
+
* capture dir.
|
|
14
|
+
* - `getDefaultCaptureDir()` — resolve (and create on first call)
|
|
15
|
+
* the canonical default capture directory:
|
|
16
|
+
*
|
|
17
|
+
* iOS: `<NSCachesDirectory>/react-native-image-stitcher/`
|
|
18
|
+
* Android: `<context.cacheDir>/react-native-image-stitcher/`
|
|
19
|
+
*
|
|
20
|
+
* Predictable, evictable-by-OS, NOT backed up. Captures live
|
|
21
|
+
* here until the host moves them somewhere durable (which is
|
|
22
|
+
* intentional — the lib doesn't promise persistence beyond the
|
|
23
|
+
* immediate capture flow).
|
|
24
|
+
*/
|
|
25
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
26
|
+
exports.moveFile = moveFile;
|
|
27
|
+
exports.getDefaultCaptureDir = getDefaultCaptureDir;
|
|
28
|
+
exports.defaultPhotoFilename = defaultPhotoFilename;
|
|
29
|
+
exports.defaultPanoramaFilename = defaultPanoramaFilename;
|
|
30
|
+
const react_native_1 = require("react-native");
|
|
31
|
+
function bridge() {
|
|
32
|
+
const m = react_native_1.NativeModules.RNImageStitcherFileUtils;
|
|
33
|
+
if (!m || typeof m !== 'object')
|
|
34
|
+
return null;
|
|
35
|
+
return m;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Move a file via the native bridge. Both paths accepted in bare
|
|
39
|
+
* or `file://`-prefixed form. Resolves to the bare destination
|
|
40
|
+
* path on success. Throws on disk failure.
|
|
41
|
+
*/
|
|
42
|
+
async function moveFile(from, to) {
|
|
43
|
+
const b = bridge();
|
|
44
|
+
if (!b) {
|
|
45
|
+
throw new Error('react-native-image-stitcher: RNImageStitcherFileUtils native '
|
|
46
|
+
+ 'module is not registered. Check that the host app has '
|
|
47
|
+
+ 'rebuilt against the latest pod/Gradle install.');
|
|
48
|
+
}
|
|
49
|
+
return b.moveFile(from, to);
|
|
50
|
+
}
|
|
51
|
+
// Cached after the first resolve — the dir doesn't move during the
|
|
52
|
+
// lifetime of the app, and the on-first-call mkdir is idempotent.
|
|
53
|
+
let cachedDefaultDir = null;
|
|
54
|
+
/**
|
|
55
|
+
* Resolve the canonical default capture directory. Lazy +
|
|
56
|
+
* memoised — the native side creates the dir on first call, JS
|
|
57
|
+
* caches the result for the rest of the app session.
|
|
58
|
+
*/
|
|
59
|
+
async function getDefaultCaptureDir() {
|
|
60
|
+
if (cachedDefaultDir !== null)
|
|
61
|
+
return cachedDefaultDir;
|
|
62
|
+
const b = bridge();
|
|
63
|
+
if (!b) {
|
|
64
|
+
throw new Error('react-native-image-stitcher: RNImageStitcherFileUtils native '
|
|
65
|
+
+ 'module is not registered. Check that the host app has '
|
|
66
|
+
+ 'rebuilt against the latest pod/Gradle install.');
|
|
67
|
+
}
|
|
68
|
+
cachedDefaultDir = await b.defaultCaptureDir();
|
|
69
|
+
return cachedDefaultDir;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Compose a default filename for a tap-photo capture, using a
|
|
73
|
+
* millisecond Unix timestamp for ordering. Pure helper; no I/O.
|
|
74
|
+
*/
|
|
75
|
+
function defaultPhotoFilename() {
|
|
76
|
+
return `photo-${Date.now()}.jpg`;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Same as `defaultPhotoFilename` but for panoramas.
|
|
80
|
+
*/
|
|
81
|
+
function defaultPanoramaFilename() {
|
|
82
|
+
return `panorama-${Date.now()}.jpg`;
|
|
83
|
+
}
|
|
84
|
+
//# sourceMappingURL=files.js.map
|