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
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path normalisation helpers. Internal — NOT re-exported from
|
|
3
|
+
* `src/index.ts`; the public surface intentionally doesn't promise
|
|
4
|
+
* these utilities to consumers (every host app has its own copy).
|
|
5
|
+
*
|
|
6
|
+
* Two shapes a file path can take when crossing the JS / native /
|
|
7
|
+
* React layers in this library:
|
|
8
|
+
*
|
|
9
|
+
* - **`file://`-prefixed URI** — what RN's `<Image source={{ uri }}>`
|
|
10
|
+
* (Android strict, iOS lenient) and `expo-file-system` APIs
|
|
11
|
+
* accept. Whenever this library emits a path to JS (via
|
|
12
|
+
* `onCapture`, the `IncrementalStateUpdate` event, etc.) it
|
|
13
|
+
* should be in this form so consumers can render it directly.
|
|
14
|
+
*
|
|
15
|
+
* - **Bare path** — what `fs`-style native APIs (`cv::imwrite`,
|
|
16
|
+
* `NSFileManager`, `BitmapFactory.decodeFile`) accept. These
|
|
17
|
+
* treat a `file://` prefix as part of the literal filename and
|
|
18
|
+
* fail to open it. Native bridges expect bare paths in.
|
|
19
|
+
*
|
|
20
|
+
* Both helpers are pure and idempotent. No-op on the empty string.
|
|
21
|
+
*
|
|
22
|
+
* (The Swift and Kotlin sides have their own `stripFileScheme` —
|
|
23
|
+
* cross-language sharing isn't worth a small helper. Keeping the
|
|
24
|
+
* JS copy here just centralises the rule for the TS surface.)
|
|
25
|
+
*/
|
|
26
|
+
/** Add the `file://` scheme to a bare path, idempotently. */
|
|
27
|
+
export declare function toFileUri(path: string | null | undefined): string;
|
|
28
|
+
/** Strip the `file://` scheme from a URI, idempotently. */
|
|
29
|
+
export declare function toBareFilePath(path: string | null | undefined): string;
|
|
30
|
+
//# sourceMappingURL=paths.d.ts.map
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
/**
|
|
4
|
+
* Path normalisation helpers. Internal — NOT re-exported from
|
|
5
|
+
* `src/index.ts`; the public surface intentionally doesn't promise
|
|
6
|
+
* these utilities to consumers (every host app has its own copy).
|
|
7
|
+
*
|
|
8
|
+
* Two shapes a file path can take when crossing the JS / native /
|
|
9
|
+
* React layers in this library:
|
|
10
|
+
*
|
|
11
|
+
* - **`file://`-prefixed URI** — what RN's `<Image source={{ uri }}>`
|
|
12
|
+
* (Android strict, iOS lenient) and `expo-file-system` APIs
|
|
13
|
+
* accept. Whenever this library emits a path to JS (via
|
|
14
|
+
* `onCapture`, the `IncrementalStateUpdate` event, etc.) it
|
|
15
|
+
* should be in this form so consumers can render it directly.
|
|
16
|
+
*
|
|
17
|
+
* - **Bare path** — what `fs`-style native APIs (`cv::imwrite`,
|
|
18
|
+
* `NSFileManager`, `BitmapFactory.decodeFile`) accept. These
|
|
19
|
+
* treat a `file://` prefix as part of the literal filename and
|
|
20
|
+
* fail to open it. Native bridges expect bare paths in.
|
|
21
|
+
*
|
|
22
|
+
* Both helpers are pure and idempotent. No-op on the empty string.
|
|
23
|
+
*
|
|
24
|
+
* (The Swift and Kotlin sides have their own `stripFileScheme` —
|
|
25
|
+
* cross-language sharing isn't worth a small helper. Keeping the
|
|
26
|
+
* JS copy here just centralises the rule for the TS surface.)
|
|
27
|
+
*/
|
|
28
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
29
|
+
exports.toFileUri = toFileUri;
|
|
30
|
+
exports.toBareFilePath = toBareFilePath;
|
|
31
|
+
/** Add the `file://` scheme to a bare path, idempotently. */
|
|
32
|
+
function toFileUri(path) {
|
|
33
|
+
if (!path)
|
|
34
|
+
return '';
|
|
35
|
+
if (path.startsWith('file://') || path.startsWith('content://') || path.startsWith('http')) {
|
|
36
|
+
return path;
|
|
37
|
+
}
|
|
38
|
+
return `file://${path}`;
|
|
39
|
+
}
|
|
40
|
+
/** Strip the `file://` scheme from a URI, idempotently. */
|
|
41
|
+
function toBareFilePath(path) {
|
|
42
|
+
if (!path)
|
|
43
|
+
return '';
|
|
44
|
+
if (path.startsWith('file://'))
|
|
45
|
+
return path.slice('file://'.length);
|
|
46
|
+
return path;
|
|
47
|
+
}
|
|
48
|
+
//# sourceMappingURL=paths.js.map
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
//
|
|
3
|
+
// FileBridge.m — Obj-C side of the React Native module registration
|
|
4
|
+
// for `FileBridge.swift`. Required because RN's bridge discovery
|
|
5
|
+
// scans for `@interface RCT_EXTERN_MODULE(...)` blocks via Obj-C
|
|
6
|
+
// runtime introspection; Swift-only modules aren't visible.
|
|
7
|
+
|
|
8
|
+
#import <React/RCTBridgeModule.h>
|
|
9
|
+
|
|
10
|
+
@interface RCT_EXTERN_MODULE(RNImageStitcherFileUtils, NSObject)
|
|
11
|
+
|
|
12
|
+
RCT_EXTERN_METHOD(moveFile:(NSString *)from
|
|
13
|
+
to:(NSString *)to
|
|
14
|
+
resolver:(RCTPromiseResolveBlock)resolver
|
|
15
|
+
rejecter:(RCTPromiseRejectBlock)rejecter)
|
|
16
|
+
|
|
17
|
+
RCT_EXTERN_METHOD(defaultCaptureDir:(RCTPromiseResolveBlock)resolver
|
|
18
|
+
rejecter:(RCTPromiseRejectBlock)rejecter)
|
|
19
|
+
|
|
20
|
+
@end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
//
|
|
3
|
+
// FileBridge.swift
|
|
4
|
+
//
|
|
5
|
+
// Small native module exposing two file operations the JS layer
|
|
6
|
+
// needs in order to:
|
|
7
|
+
//
|
|
8
|
+
// 1. Move vision-camera's auto-named tmp photo into our canonical
|
|
9
|
+
// default capture dir, so JS-level paths returned to the host
|
|
10
|
+
// are predictable (vs. opaque `<uuid>.jpg` paths in
|
|
11
|
+
// `NSTemporaryDirectory()`).
|
|
12
|
+
// 2. Resolve the canonical default capture dir itself, so the JS
|
|
13
|
+
// layer can compose `<defaultDir>/photo-<ms>.jpg` filenames
|
|
14
|
+
// consistently across both platforms.
|
|
15
|
+
//
|
|
16
|
+
// Kept narrow on purpose — this isn't a general-purpose fs API. If
|
|
17
|
+
// host apps want to read/write arbitrary files they can pull in
|
|
18
|
+
// `expo-file-system` themselves; the lib only exposes what it needs
|
|
19
|
+
// for its own capture flow.
|
|
20
|
+
//
|
|
21
|
+
// Canonical capture dir lives under `NSCachesDirectory` (`Library/
|
|
22
|
+
// Caches/`) because:
|
|
23
|
+
// * It persists across app restarts (unlike `NSTemporaryDirectory()`).
|
|
24
|
+
// * iOS may evict cache files under memory pressure, which matches
|
|
25
|
+
// the lib's contract: "capture file lives until host moves it
|
|
26
|
+
// somewhere durable."
|
|
27
|
+
// * Not backed up to iCloud, so the user doesn't pay for the
|
|
28
|
+
// ephemeral capture files.
|
|
29
|
+
|
|
30
|
+
import Foundation
|
|
31
|
+
import React
|
|
32
|
+
|
|
33
|
+
@objc(RNImageStitcherFileUtils)
|
|
34
|
+
public class FileBridge: NSObject {
|
|
35
|
+
|
|
36
|
+
@objc public static func requiresMainQueueSetup() -> Bool {
|
|
37
|
+
return false
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/// Move (or copy+delete fallback for cross-volume moves) a file
|
|
41
|
+
/// from `from` to `to`. Both paths can be bare or `file://`-prefixed
|
|
42
|
+
/// — bridge normalises internally. Creates the destination's
|
|
43
|
+
/// parent directory tree if missing. Resolves to the bare
|
|
44
|
+
/// destination path.
|
|
45
|
+
@objc(moveFile:to:resolver:rejecter:)
|
|
46
|
+
public func moveFile(_ from: String,
|
|
47
|
+
to dst: String,
|
|
48
|
+
resolver: @escaping RCTPromiseResolveBlock,
|
|
49
|
+
rejecter: @escaping RCTPromiseRejectBlock) {
|
|
50
|
+
let fm = FileManager.default
|
|
51
|
+
let cleanFrom = from.hasPrefix("file://") ? String(from.dropFirst(7)) : from
|
|
52
|
+
let cleanTo = dst.hasPrefix("file://") ? String(dst.dropFirst(7)) : dst
|
|
53
|
+
do {
|
|
54
|
+
let dstDir = (cleanTo as NSString).deletingLastPathComponent
|
|
55
|
+
if !fm.fileExists(atPath: dstDir) {
|
|
56
|
+
try fm.createDirectory(
|
|
57
|
+
atPath: dstDir,
|
|
58
|
+
withIntermediateDirectories: true,
|
|
59
|
+
attributes: nil,
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
if fm.fileExists(atPath: cleanTo) {
|
|
63
|
+
try fm.removeItem(atPath: cleanTo)
|
|
64
|
+
}
|
|
65
|
+
// Cheap rename first (same volume). iOS caches + tmp are on the
|
|
66
|
+
// same APFS volume so this is fast; the catch is for the
|
|
67
|
+
// theoretical cross-volume move that copyItem can still handle.
|
|
68
|
+
do {
|
|
69
|
+
try fm.moveItem(atPath: cleanFrom, toPath: cleanTo)
|
|
70
|
+
} catch {
|
|
71
|
+
try fm.copyItem(atPath: cleanFrom, toPath: cleanTo)
|
|
72
|
+
try? fm.removeItem(atPath: cleanFrom)
|
|
73
|
+
}
|
|
74
|
+
resolver(cleanTo)
|
|
75
|
+
} catch {
|
|
76
|
+
rejecter(
|
|
77
|
+
"FILE_MOVE_FAILED",
|
|
78
|
+
"Failed to move \(cleanFrom) → \(cleanTo): \(error.localizedDescription)",
|
|
79
|
+
error,
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/// Resolve the lib's canonical default capture dir, creating it on
|
|
85
|
+
/// demand. Returns a bare absolute path.
|
|
86
|
+
@objc(defaultCaptureDir:rejecter:)
|
|
87
|
+
public func defaultCaptureDir(_ resolver: @escaping RCTPromiseResolveBlock,
|
|
88
|
+
rejecter: @escaping RCTPromiseRejectBlock) {
|
|
89
|
+
let caches = NSSearchPathForDirectoriesInDomains(
|
|
90
|
+
.cachesDirectory, .userDomainMask, true,
|
|
91
|
+
).first ?? NSTemporaryDirectory()
|
|
92
|
+
let dir = (caches as NSString).appendingPathComponent("react-native-image-stitcher")
|
|
93
|
+
do {
|
|
94
|
+
if !FileManager.default.fileExists(atPath: dir) {
|
|
95
|
+
try FileManager.default.createDirectory(
|
|
96
|
+
atPath: dir,
|
|
97
|
+
withIntermediateDirectories: true,
|
|
98
|
+
attributes: nil,
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
resolver(dir)
|
|
102
|
+
} catch {
|
|
103
|
+
rejecter(
|
|
104
|
+
"DIR_CREATE_FAILED",
|
|
105
|
+
"Failed to create canonical capture dir \(dir): \(error.localizedDescription)",
|
|
106
|
+
error,
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-native-image-stitcher",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Pose-aware panorama capture + stitching for React Native. One <Camera> component, both tap-to-photo and hold-to-pan modes, both AR-backed and IMU-fallback capture paths.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
package/src/camera/Camera.tsx
CHANGED
|
@@ -80,6 +80,13 @@ import {
|
|
|
80
80
|
import { useIncrementalJSDriver } from '../stitching/useIncrementalJSDriver';
|
|
81
81
|
import { useIncrementalStitcher } from '../stitching/useIncrementalStitcher';
|
|
82
82
|
import { useIMUTranslationGate } from '../sensors/useIMUTranslationGate';
|
|
83
|
+
import { toBareFilePath, toFileUri } from '../utils/paths';
|
|
84
|
+
import {
|
|
85
|
+
defaultPanoramaFilename,
|
|
86
|
+
defaultPhotoFilename,
|
|
87
|
+
getDefaultCaptureDir,
|
|
88
|
+
moveFile,
|
|
89
|
+
} from '../utils/files';
|
|
83
90
|
|
|
84
91
|
|
|
85
92
|
// ─── Types ──────────────────────────────────────────────────────────
|
|
@@ -139,6 +146,7 @@ export type CameraErrorCode =
|
|
|
139
146
|
| 'STITCH_HOMOGRAPHY_FAIL'
|
|
140
147
|
| 'STITCH_CAMERA_PARAMS_FAIL'
|
|
141
148
|
| 'STITCH_OOM'
|
|
149
|
+
| 'OUTPUT_WRITE_FAILED'
|
|
142
150
|
| 'UNKNOWN';
|
|
143
151
|
|
|
144
152
|
|
|
@@ -196,6 +204,35 @@ export interface CameraProps {
|
|
|
196
204
|
showSettingsButton?: boolean;
|
|
197
205
|
style?: StyleProp<ViewStyle>;
|
|
198
206
|
|
|
207
|
+
/**
|
|
208
|
+
* Optional destination directory for captures. When set, the lib
|
|
209
|
+
* lands tap-photos at `${outputDir}/photo-${ts}.jpg` and panoramas
|
|
210
|
+
* at `${outputDir}/panorama-${ts}.jpg` and the returned uri points
|
|
211
|
+
* at the persisted file (vs. vision-camera's tmp dir, which is
|
|
212
|
+
* what you get when this prop is omitted).
|
|
213
|
+
*
|
|
214
|
+
* The host is solely responsible for:
|
|
215
|
+
* - Choosing a writable directory (the lib does NOT pick this for
|
|
216
|
+
* you on either platform — particularly relevant on Android,
|
|
217
|
+
* where scoped-storage rules differ between app-private storage
|
|
218
|
+
* and user-visible Documents/Pictures dirs).
|
|
219
|
+
* - Ensuring the directory exists. The lib will create it if it
|
|
220
|
+
* doesn't, but only inside paths the OS lets it write to.
|
|
221
|
+
* - Making the path user-visible if that matters (`UIFileSharingEnabled`
|
|
222
|
+
* on iOS for `FileSystem.documentDirectory`; MediaStore /
|
|
223
|
+
* `Documents/...` on Android — see your platform's docs).
|
|
224
|
+
*
|
|
225
|
+
* On disk failure the capture promise rejects via `onError` with
|
|
226
|
+
* `CameraError('OUTPUT_WRITE_FAILED', ...)`. No silent fallback to
|
|
227
|
+
* tmp — that hides bugs.
|
|
228
|
+
*
|
|
229
|
+
* Requires `expo-file-system` (declared as an OPTIONAL peer dep;
|
|
230
|
+
* only needed when this prop is set).
|
|
231
|
+
*
|
|
232
|
+
* Format: bare path or `file://` URI. Both accepted.
|
|
233
|
+
*/
|
|
234
|
+
outputDir?: string;
|
|
235
|
+
|
|
199
236
|
// ── Callbacks ─────────────────────────────────────────────────────
|
|
200
237
|
onCapture?: (result: CameraCaptureResult) => void;
|
|
201
238
|
onCaptureSourceChange?: (source: CaptureSource) => void;
|
|
@@ -458,29 +495,14 @@ function buildInitialSettings(props: CameraProps): PanoramaSettings {
|
|
|
458
495
|
}
|
|
459
496
|
|
|
460
497
|
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
* normalised in `makeCaptureResult`).
|
|
470
|
-
* - ARCameraView.takePhoto, IncrementalStitcher.finalize, and the
|
|
471
|
-
* `batchKeyframeThumbnailPath` from `IncrementalStateUpdate` all
|
|
472
|
-
* return bare paths. Those are the cases this helper handles.
|
|
473
|
-
*
|
|
474
|
-
* Already-prefixed inputs are passed through unchanged, so it's safe
|
|
475
|
-
* to call defensively at every public-API boundary.
|
|
476
|
-
*/
|
|
477
|
-
function ensureFileUri(path: string | null | undefined): string {
|
|
478
|
-
if (!path) return '';
|
|
479
|
-
if (path.startsWith('file://') || path.startsWith('content://') || path.startsWith('http')) {
|
|
480
|
-
return path;
|
|
481
|
-
}
|
|
482
|
-
return `file://${path}`;
|
|
483
|
-
}
|
|
498
|
+
// `toFileUri` (used to be an inline `toFileUri` here) lives in
|
|
499
|
+
// `../utils/paths.ts` so every call-site in this lib funnels through
|
|
500
|
+
// one canonical implementation. Native bridges return paths in
|
|
501
|
+
// mixed shapes — useCapture.compressedUri already has `file://`,
|
|
502
|
+
// while ARCameraView.takePhoto + IncrementalStitcher.finalize +
|
|
503
|
+
// `batchKeyframeThumbnailPath` events all return bare paths — and we
|
|
504
|
+
// normalise to the URI form on the way out to JS consumers (Android
|
|
505
|
+
// `<Image>` requires the scheme; iOS is lenient).
|
|
484
506
|
|
|
485
507
|
|
|
486
508
|
/**
|
|
@@ -494,6 +516,7 @@ export function Camera(props: CameraProps): React.JSX.Element {
|
|
|
494
516
|
enablePanoramaMode = true,
|
|
495
517
|
showSettingsButton = false,
|
|
496
518
|
style,
|
|
519
|
+
outputDir,
|
|
497
520
|
onCapture,
|
|
498
521
|
onCaptureSourceChange,
|
|
499
522
|
onLensChange,
|
|
@@ -683,7 +706,7 @@ export function Camera(props: CameraProps): React.JSX.Element {
|
|
|
683
706
|
// De-dupe — same path may emit on subsequent ticks.
|
|
684
707
|
// Normalise to `file://...` so Android <Image> in the band
|
|
685
708
|
// overlay can actually render the thumbnail.
|
|
686
|
-
const path =
|
|
709
|
+
const path = toFileUri(state.batchKeyframeThumbnailPath!);
|
|
687
710
|
if (prev.includes(path)) return prev;
|
|
688
711
|
return [...prev, path];
|
|
689
712
|
});
|
|
@@ -706,12 +729,32 @@ export function Camera(props: CameraProps): React.JSX.Element {
|
|
|
706
729
|
let uri: string;
|
|
707
730
|
let width: number;
|
|
708
731
|
let height: number;
|
|
732
|
+
// Compose the destination path BEFORE the capture so both the
|
|
733
|
+
// AR and non-AR branches land at the same predictable location.
|
|
734
|
+
// If `outputDir` is set, the lib lands the file at a host-
|
|
735
|
+
// controlled path; otherwise, in the lib's canonical capture
|
|
736
|
+
// dir (`<cache>/react-native-image-stitcher/photo-<ms>.jpg`).
|
|
737
|
+
const photoOutputPath = outputDir
|
|
738
|
+
? `${toBareFilePath(outputDir).replace(/\/$/, '')}/${defaultPhotoFilename()}`
|
|
739
|
+
: `${await getDefaultCaptureDir()}/${defaultPhotoFilename()}`;
|
|
709
740
|
if (isAR && arViewRef.current) {
|
|
741
|
+
// ARCameraView writes to its own tmp location; relocate to
|
|
742
|
+
// photoOutputPath via the native FileBridge so both branches
|
|
743
|
+
// return paths under the same dir.
|
|
710
744
|
const photo = await arViewRef.current.takePhoto({ quality: 90 });
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
745
|
+
try {
|
|
746
|
+
await moveFile(photo.path, photoOutputPath);
|
|
747
|
+
} catch (moveErr) {
|
|
748
|
+
throw new CameraError(
|
|
749
|
+
'OUTPUT_WRITE_FAILED',
|
|
750
|
+
`Failed to move AR photo to ${photoOutputPath}. The destination `
|
|
751
|
+
+ 'directory must be writable.',
|
|
752
|
+
moveErr,
|
|
753
|
+
);
|
|
754
|
+
}
|
|
755
|
+
// Android <Image> needs the `file://` scheme to render the
|
|
756
|
+
// returned uri; iOS is OK either way. Normalise once here.
|
|
757
|
+
uri = toFileUri(photoOutputPath);
|
|
715
758
|
width = photo.width;
|
|
716
759
|
height = photo.height;
|
|
717
760
|
} else {
|
|
@@ -724,12 +767,11 @@ export function Camera(props: CameraProps): React.JSX.Element {
|
|
|
724
767
|
// useCapture.takePhoto wraps the cameraRef internally;
|
|
725
768
|
// attach via assignment so the hook's ref points at our
|
|
726
769
|
// local ref. This works because RefObject is just { current }.
|
|
727
|
-
// Effect: capture.takePhoto() resolves with the SDK's
|
|
728
|
-
// CaptureResult (with compressedUri / width / height).
|
|
729
|
-
// We adapt to the public CameraCaptureResult shape.
|
|
730
770
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
731
771
|
(capture.cameraRef as any).current = visionCameraRef.current;
|
|
732
|
-
|
|
772
|
+
// useCapture handles the move internally; the returned
|
|
773
|
+
// `compressedUri` already points at `photoOutputPath`.
|
|
774
|
+
const result = await capture.takePhoto({ outputPath: photoOutputPath });
|
|
733
775
|
uri = result.compressedUri;
|
|
734
776
|
width = result.width;
|
|
735
777
|
height = result.height;
|
|
@@ -745,7 +787,7 @@ export function Camera(props: CameraProps): React.JSX.Element {
|
|
|
745
787
|
);
|
|
746
788
|
onError?.(e);
|
|
747
789
|
}
|
|
748
|
-
}, [enablePhotoMode, isAR, capture, onCapture, onError]);
|
|
790
|
+
}, [enablePhotoMode, isAR, capture, outputDir, onCapture, onError]);
|
|
749
791
|
|
|
750
792
|
const handleHoldStart = useCallback(async () => {
|
|
751
793
|
if (!enablePanoramaMode) return;
|
|
@@ -828,8 +870,16 @@ export function Camera(props: CameraProps): React.JSX.Element {
|
|
|
828
870
|
// No-op in AR mode where jsDriver was never started.
|
|
829
871
|
jsDriver.stop();
|
|
830
872
|
try {
|
|
873
|
+
// Compose the panorama output path: host-controlled if
|
|
874
|
+
// `outputDir` is set, else the lib's canonical capture dir
|
|
875
|
+
// (`<cache>/react-native-image-stitcher/panorama-<ms>.jpg`).
|
|
876
|
+
// `incremental.finalize` writes the stitched JPEG straight to
|
|
877
|
+
// this path natively (no JS-side move needed for panoramas).
|
|
878
|
+
const panoOutputPath = outputDir
|
|
879
|
+
? `${toBareFilePath(outputDir).replace(/\/$/, '')}/${defaultPanoramaFilename()}`
|
|
880
|
+
: `${await getDefaultCaptureDir()}/${defaultPanoramaFilename()}`;
|
|
831
881
|
const result = await incremental.finalize(
|
|
832
|
-
|
|
882
|
+
panoOutputPath,
|
|
833
883
|
90,
|
|
834
884
|
deviceOrientation,
|
|
835
885
|
);
|
|
@@ -847,7 +897,7 @@ export function Camera(props: CameraProps): React.JSX.Element {
|
|
|
847
897
|
type: 'panorama',
|
|
848
898
|
// Native finalize() returns a bare `/data/.../foo.jpg` path;
|
|
849
899
|
// normalise to `file://` for Android <Image>.
|
|
850
|
-
uri:
|
|
900
|
+
uri: toFileUri(result.panoramaPath),
|
|
851
901
|
width: result.width,
|
|
852
902
|
height: result.height,
|
|
853
903
|
framesRequested: result.framesRequested ?? -1,
|
package/src/camera/useCapture.ts
CHANGED
|
@@ -38,6 +38,12 @@ import {
|
|
|
38
38
|
|
|
39
39
|
import { runQualityCheck } from '../quality/runQualityCheck';
|
|
40
40
|
import { normaliseOrientation } from '../quality/normaliseOrientation';
|
|
41
|
+
import { toBareFilePath } from '../utils/paths';
|
|
42
|
+
import {
|
|
43
|
+
defaultPhotoFilename,
|
|
44
|
+
getDefaultCaptureDir,
|
|
45
|
+
moveFile,
|
|
46
|
+
} from '../utils/files';
|
|
41
47
|
import type {
|
|
42
48
|
CaptureResult,
|
|
43
49
|
QualityReport,
|
|
@@ -91,6 +97,27 @@ export interface UseCaptureOptions {
|
|
|
91
97
|
}
|
|
92
98
|
|
|
93
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Per-call options for `takePhoto`. Separate from `UseCaptureOptions`
|
|
102
|
+
* (the hook-level config) so callers can vary the destination
|
|
103
|
+
* filename per capture without re-creating the hook.
|
|
104
|
+
*/
|
|
105
|
+
export interface TakePhotoCallOptions {
|
|
106
|
+
/**
|
|
107
|
+
* Move the captured JPEG to this fully-resolved path after EXIF
|
|
108
|
+
* orientation correction. Requires `expo-file-system` in the
|
|
109
|
+
* host (declared as an OPTIONAL peer — only needed when
|
|
110
|
+
* `outputPath` is set). Host is responsible for the destination
|
|
111
|
+
* directory's existence and writability; lib rejects loudly on
|
|
112
|
+
* disk failure rather than silently falling back to a tmp path.
|
|
113
|
+
*
|
|
114
|
+
* Format: bare path (e.g. `/data/.../foo.jpg`) or `file://`-prefixed
|
|
115
|
+
* URI — both accepted; lib normalises internally.
|
|
116
|
+
*/
|
|
117
|
+
outputPath?: string;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
|
|
94
121
|
/**
|
|
95
122
|
* Hook output. Intentionally flat so destructuring a subset is
|
|
96
123
|
* cheap and the API doesn't force callers to drill into nested
|
|
@@ -114,8 +141,19 @@ export interface UseCaptureReturn {
|
|
|
114
141
|
* Take a photo. Single-flight: parallel calls return the in-flight
|
|
115
142
|
* promise. Returns a CaptureResult (with an optional QualityReport
|
|
116
143
|
* when ``enableQualityChecks`` is on).
|
|
144
|
+
*
|
|
145
|
+
* `outputPath` (optional): a fully-resolved destination path. When
|
|
146
|
+
* set, the lib moves the captured JPEG to that path after EXIF
|
|
147
|
+
* orientation correction, and the returned `compressedUri` points
|
|
148
|
+
* at the moved file. The host is responsible for ensuring the
|
|
149
|
+
* destination directory exists and is writable; on disk failure,
|
|
150
|
+
* the promise rejects with an error referencing `outputPath`.
|
|
151
|
+
*
|
|
152
|
+
* Requires `expo-file-system` to be installed in the host app
|
|
153
|
+
* (declared as an OPTIONAL peer dep — consumers that don't pass
|
|
154
|
+
* `outputPath` aren't required to have it).
|
|
117
155
|
*/
|
|
118
|
-
takePhoto: () => Promise<CaptureResult>;
|
|
156
|
+
takePhoto: (options?: TakePhotoCallOptions) => Promise<CaptureResult>;
|
|
119
157
|
/**
|
|
120
158
|
* 2026-05-14 — physical lens types available on the chosen
|
|
121
159
|
* `cameraPosition`. Computed once at the first vision-camera
|
|
@@ -217,7 +255,7 @@ export function useCapture(options: UseCaptureOptions = {}): UseCaptureReturn {
|
|
|
217
255
|
setFlash((prev) => (prev === 'off' ? 'on' : 'off'));
|
|
218
256
|
}, []);
|
|
219
257
|
|
|
220
|
-
const takePhoto = useCallback(async (): Promise<CaptureResult> => {
|
|
258
|
+
const takePhoto = useCallback(async (callOptions?: TakePhotoCallOptions): Promise<CaptureResult> => {
|
|
221
259
|
if (inFlightRef.current) {
|
|
222
260
|
return inFlightRef.current;
|
|
223
261
|
}
|
|
@@ -245,12 +283,33 @@ export function useCapture(options: UseCaptureOptions = {}): UseCaptureReturn {
|
|
|
245
283
|
width: photo.width,
|
|
246
284
|
height: photo.height,
|
|
247
285
|
});
|
|
248
|
-
|
|
286
|
+
let orientedPhoto: PhotoFile = {
|
|
249
287
|
...photo,
|
|
250
288
|
width: normalised.width || photo.width,
|
|
251
289
|
height: normalised.height || photo.height,
|
|
252
290
|
};
|
|
253
291
|
|
|
292
|
+
// Move the orientation-corrected file to its final location.
|
|
293
|
+
// If the caller passed `outputPath`, use that. Otherwise, the
|
|
294
|
+
// lib publishes captures into its canonical default dir so
|
|
295
|
+
// returned paths are predictable across consumers (vs.
|
|
296
|
+
// vision-camera's auto-generated UUID-named tmp file). The
|
|
297
|
+
// move is performed via the `RNImageStitcherFileUtils` native
|
|
298
|
+
// bridge — no peer-dep on `expo-file-system` etc.
|
|
299
|
+
try {
|
|
300
|
+
const dstPath = callOptions?.outputPath
|
|
301
|
+
? toBareFilePath(callOptions.outputPath)
|
|
302
|
+
: `${await getDefaultCaptureDir()}/${defaultPhotoFilename()}`;
|
|
303
|
+
await moveFile(orientedPhoto.path, dstPath);
|
|
304
|
+
orientedPhoto = { ...orientedPhoto, path: dstPath };
|
|
305
|
+
} catch (e) {
|
|
306
|
+
throw new Error(
|
|
307
|
+
'useCapture.takePhoto: failed to move captured photo to its '
|
|
308
|
+
+ `destination${callOptions?.outputPath ? ` (${callOptions.outputPath})` : ' (default capture dir)'}. `
|
|
309
|
+
+ `Underlying: ${e instanceof Error ? e.message : String(e)}`,
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
254
313
|
let report: QualityReport | undefined;
|
|
255
314
|
if (enableQualityChecks && qualityThresholds) {
|
|
256
315
|
report = await runQualityCheck(orientedPhoto.path, qualityThresholds);
|
package/src/index.ts
CHANGED
|
@@ -98,6 +98,7 @@ export { ViewportCropOverlay } from './camera/ViewportCropOverlay';
|
|
|
98
98
|
// vision-camera wrappers (useCapture / useVideoCapture) + a
|
|
99
99
|
// device-orientation reader that works under iOS portrait-lock.
|
|
100
100
|
export { useCapture } from './camera/useCapture';
|
|
101
|
+
export type { TakePhotoCallOptions } from './camera/useCapture';
|
|
101
102
|
export { useVideoCapture } from './camera/useVideoCapture';
|
|
102
103
|
export { useDeviceOrientation } from './camera/useDeviceOrientation';
|
|
103
104
|
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Thin JS wrapper around the `RNImageStitcherFileUtils` native
|
|
4
|
+
* module (defined in `ios/Sources/RNImageStitcher/FileBridge.{swift,m}`
|
|
5
|
+
* and `android/src/main/.../FileBridge.kt`). Internal — not
|
|
6
|
+
* re-exported from `src/index.ts`.
|
|
7
|
+
*
|
|
8
|
+
* Two operations:
|
|
9
|
+
* - `moveFile(from, to)` — move a file, creating the destination's
|
|
10
|
+
* parent directory tree on demand. Used to relocate
|
|
11
|
+
* vision-camera's auto-named tmp output into the lib's canonical
|
|
12
|
+
* capture dir.
|
|
13
|
+
* - `getDefaultCaptureDir()` — resolve (and create on first call)
|
|
14
|
+
* the canonical default capture directory:
|
|
15
|
+
*
|
|
16
|
+
* iOS: `<NSCachesDirectory>/react-native-image-stitcher/`
|
|
17
|
+
* Android: `<context.cacheDir>/react-native-image-stitcher/`
|
|
18
|
+
*
|
|
19
|
+
* Predictable, evictable-by-OS, NOT backed up. Captures live
|
|
20
|
+
* here until the host moves them somewhere durable (which is
|
|
21
|
+
* intentional — the lib doesn't promise persistence beyond the
|
|
22
|
+
* immediate capture flow).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { NativeModules } from 'react-native';
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
interface FileUtilsBridge {
|
|
29
|
+
moveFile(from: string, to: string): Promise<string>;
|
|
30
|
+
defaultCaptureDir(): Promise<string>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
function bridge(): FileUtilsBridge | null {
|
|
35
|
+
const m = (NativeModules as Record<string, unknown>).RNImageStitcherFileUtils;
|
|
36
|
+
if (!m || typeof m !== 'object') return null;
|
|
37
|
+
return m as FileUtilsBridge;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Move a file via the native bridge. Both paths accepted in bare
|
|
43
|
+
* or `file://`-prefixed form. Resolves to the bare destination
|
|
44
|
+
* path on success. Throws on disk failure.
|
|
45
|
+
*/
|
|
46
|
+
export async function moveFile(from: string, to: string): Promise<string> {
|
|
47
|
+
const b = bridge();
|
|
48
|
+
if (!b) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
'react-native-image-stitcher: RNImageStitcherFileUtils native '
|
|
51
|
+
+ 'module is not registered. Check that the host app has '
|
|
52
|
+
+ 'rebuilt against the latest pod/Gradle install.',
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
return b.moveFile(from, to);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
// Cached after the first resolve — the dir doesn't move during the
|
|
60
|
+
// lifetime of the app, and the on-first-call mkdir is idempotent.
|
|
61
|
+
let cachedDefaultDir: string | null = null;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Resolve the canonical default capture directory. Lazy +
|
|
65
|
+
* memoised — the native side creates the dir on first call, JS
|
|
66
|
+
* caches the result for the rest of the app session.
|
|
67
|
+
*/
|
|
68
|
+
export async function getDefaultCaptureDir(): Promise<string> {
|
|
69
|
+
if (cachedDefaultDir !== null) return cachedDefaultDir;
|
|
70
|
+
const b = bridge();
|
|
71
|
+
if (!b) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
'react-native-image-stitcher: RNImageStitcherFileUtils native '
|
|
74
|
+
+ 'module is not registered. Check that the host app has '
|
|
75
|
+
+ 'rebuilt against the latest pod/Gradle install.',
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
cachedDefaultDir = await b.defaultCaptureDir();
|
|
79
|
+
return cachedDefaultDir;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Compose a default filename for a tap-photo capture, using a
|
|
85
|
+
* millisecond Unix timestamp for ordering. Pure helper; no I/O.
|
|
86
|
+
*/
|
|
87
|
+
export function defaultPhotoFilename(): string {
|
|
88
|
+
return `photo-${Date.now()}.jpg`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Same as `defaultPhotoFilename` but for panoramas.
|
|
94
|
+
*/
|
|
95
|
+
export function defaultPanoramaFilename(): string {
|
|
96
|
+
return `panorama-${Date.now()}.jpg`;
|
|
97
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Path normalisation helpers. Internal — NOT re-exported from
|
|
4
|
+
* `src/index.ts`; the public surface intentionally doesn't promise
|
|
5
|
+
* these utilities to consumers (every host app has its own copy).
|
|
6
|
+
*
|
|
7
|
+
* Two shapes a file path can take when crossing the JS / native /
|
|
8
|
+
* React layers in this library:
|
|
9
|
+
*
|
|
10
|
+
* - **`file://`-prefixed URI** — what RN's `<Image source={{ uri }}>`
|
|
11
|
+
* (Android strict, iOS lenient) and `expo-file-system` APIs
|
|
12
|
+
* accept. Whenever this library emits a path to JS (via
|
|
13
|
+
* `onCapture`, the `IncrementalStateUpdate` event, etc.) it
|
|
14
|
+
* should be in this form so consumers can render it directly.
|
|
15
|
+
*
|
|
16
|
+
* - **Bare path** — what `fs`-style native APIs (`cv::imwrite`,
|
|
17
|
+
* `NSFileManager`, `BitmapFactory.decodeFile`) accept. These
|
|
18
|
+
* treat a `file://` prefix as part of the literal filename and
|
|
19
|
+
* fail to open it. Native bridges expect bare paths in.
|
|
20
|
+
*
|
|
21
|
+
* Both helpers are pure and idempotent. No-op on the empty string.
|
|
22
|
+
*
|
|
23
|
+
* (The Swift and Kotlin sides have their own `stripFileScheme` —
|
|
24
|
+
* cross-language sharing isn't worth a small helper. Keeping the
|
|
25
|
+
* JS copy here just centralises the rule for the TS surface.)
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** Add the `file://` scheme to a bare path, idempotently. */
|
|
29
|
+
export function toFileUri(path: string | null | undefined): string {
|
|
30
|
+
if (!path) return '';
|
|
31
|
+
if (path.startsWith('file://') || path.startsWith('content://') || path.startsWith('http')) {
|
|
32
|
+
return path;
|
|
33
|
+
}
|
|
34
|
+
return `file://${path}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Strip the `file://` scheme from a URI, idempotently. */
|
|
38
|
+
export function toBareFilePath(path: string | null | undefined): string {
|
|
39
|
+
if (!path) return '';
|
|
40
|
+
if (path.startsWith('file://')) return path.slice('file://'.length);
|
|
41
|
+
return path;
|
|
42
|
+
}
|