react-native-image-stitcher 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/CHANGELOG.md +115 -1
  2. package/README.md +0 -9
  3. package/RNImageStitcher.podspec +10 -4
  4. package/android/src/main/cpp/keyframe_gate_jni.cpp +1 -1
  5. package/android/src/main/java/io/imagestitcher/rn/BatchStitcher.kt +1 -1
  6. package/android/src/main/java/io/imagestitcher/rn/FileBridge.kt +79 -0
  7. package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +11 -3
  8. package/android/src/main/java/io/imagestitcher/rn/KeyframeGate.kt +1 -1
  9. package/android/src/main/java/io/imagestitcher/rn/RNImageStitcherPackage.kt +1 -0
  10. package/dist/camera/Camera.d.ts +29 -1
  11. package/dist/camera/Camera.js +47 -37
  12. package/dist/camera/useCapture.d.ts +31 -1
  13. package/dist/camera/useCapture.js +23 -2
  14. package/dist/index.d.ts +1 -0
  15. package/dist/stitching/stitchFrames.d.ts +1 -1
  16. package/dist/stitching/stitchFrames.js +1 -1
  17. package/dist/utils/files.d.ts +44 -0
  18. package/dist/utils/files.js +84 -0
  19. package/dist/utils/paths.d.ts +30 -0
  20. package/dist/utils/paths.js +48 -0
  21. package/ios/Package.swift +1 -1
  22. package/ios/Sources/RNImageStitcher/FileBridge.m +20 -0
  23. package/ios/Sources/RNImageStitcher/FileBridge.swift +110 -0
  24. package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +4 -4
  25. package/ios/Sources/RNImageStitcher/KeyframeGate.swift +1 -1
  26. package/ios/Sources/RNImageStitcher/KeyframeGateBridge.h +1 -1
  27. package/ios/Sources/RNImageStitcher/KeyframeGateBridge.mm +1 -1
  28. package/ios/Sources/RNImageStitcher/RNSARSession.swift +1 -1
  29. package/package.json +1 -1
  30. package/src/camera/Camera.tsx +85 -35
  31. package/src/camera/useCapture.ts +62 -3
  32. package/src/index.ts +1 -0
  33. package/src/stitching/stitchFrames.ts +1 -1
  34. package/src/utils/files.ts +97 -0
  35. package/src/utils/paths.ts +42 -0
@@ -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
@@ -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
package/ios/Package.swift CHANGED
@@ -16,7 +16,7 @@
16
16
  //
17
17
  // Run from this directory:
18
18
  //
19
- // cd retailens-capture-sdk/ios
19
+ // cd react-native-image-stitcher/ios
20
20
  // swift test
21
21
 
22
22
  import PackageDescription
@@ -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
+ }
@@ -289,7 +289,7 @@ public final class IncrementalStitcher: NSObject {
289
289
  /// fix is non-trivial; deferred until pose-driven stitch work
290
290
  /// lands (which will rework the queue topology anyway).
291
291
  private let workQueue = DispatchQueue(
292
- label: "com.retailens.incremental.stitcher",
292
+ label: "io.imagestitcher.incremental.stitcher",
293
293
  qos: .userInitiated
294
294
  )
295
295
 
@@ -306,7 +306,7 @@ public final class IncrementalStitcher: NSObject {
306
306
  /// is out of scope for this MVP — see prompt's "deliberately out
307
307
  /// of scope" list).
308
308
  private let refineQueue = DispatchQueue(
309
- label: "com.retailens.incremental.refine",
309
+ label: "io.imagestitcher.incremental.refine",
310
310
  qos: .utility
311
311
  )
312
312
 
@@ -1136,7 +1136,7 @@ public final class IncrementalStitcher: NSObject {
1136
1136
  // Why this matters (RCA from Sentry crashes 2026-05-09
1137
1137
  // 21:59-22:03, all 3 .ips traces):
1138
1138
  // EXC_BAD_ACCESS at objc_retain+16, frame 1 = closure #1
1139
- // in finalize+2648, queue = com.retailens.incremental.
1139
+ // in finalize+2648, queue = io.imagestitcher.incremental.
1140
1140
  // stitcher. +2648 lands inside the os_log call that
1141
1141
  // bridges self.batchWarperType → NSString via
1142
1142
  // swift_bridgeObjectRetain → objc_retain. The retain
@@ -1269,7 +1269,7 @@ public final class IncrementalStitcher: NSObject {
1269
1269
  // under stateLock, closing the visible torn-pointer race.
1270
1270
  // Three Sentry traces post-fix4 still showed the same crash
1271
1271
  // signature (frame 1 = closure #1 in finalize+N, queue =
1272
- // com.retailens.incremental.stitcher), which per the
1272
+ // io.imagestitcher.incremental.stitcher), which per the
1273
1273
  // systematic-debugging skill (3+ fixes failed on the same
1274
1274
  // symptom = wrong architecture) means the workQueue.async
1275
1275
  // pattern itself is the problem, not any specific captured
@@ -4,7 +4,7 @@
4
4
  //
5
5
  // This file used to BE the algorithm (~545 lines of Swift simd math).
6
6
  // As of P3-B of the Android-iOS parity work, the algorithm lives in
7
- // retailens-capture-sdk/cpp/keyframe_gate.{hpp,cpp} and is shared with
7
+ // react-native-image-stitcher/cpp/keyframe_gate.{hpp,cpp} and is shared with
8
8
  // the Android side via JNI. This Swift class is now a thin facade
9
9
  // that:
10
10
  //
@@ -1,7 +1,7 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  //
3
3
  // KeyframeGateBridge.h — Obj-C++ wrapper exposing the shared C++
4
- // KeyframeGate (in retailens-capture-sdk/cpp/) to Swift.
4
+ // KeyframeGate (in react-native-image-stitcher/cpp/) to Swift.
5
5
  //
6
6
  // Why this exists:
7
7
  // The pose-driven keyframe-selection algorithm is the single most
@@ -18,7 +18,7 @@
18
18
  // Single source of truth for the reason-code → string mapping. These
19
19
  // strings MUST stay 1:1 with the labels emitted by the original
20
20
  // KeyframeGate.swift (and read by the JS telemetry layer in
21
- // retailens-capture-sdk/src/stitching/incremental.ts). Drift will
21
+ // react-native-image-stitcher/src/stitching/incremental.ts). Drift will
22
22
  // silently break the JS UI's pill text.
23
23
  static NSString *kReasonStringFor(retailens::KeyframeGateDecisionReason r) {
24
24
  using R = retailens::KeyframeGateDecisionReason;
@@ -150,7 +150,7 @@ public final class RNSARSession: NSObject, ARSessionDelegate {
150
150
  /// recordings. Phase 5 stitching will query by timestamp.
151
151
  private var poseLog: [(TimeInterval, RNSARFramePose)] = []
152
152
  private let poseLogQueue = DispatchQueue(
153
- label: "com.retailens.arsession.poselog",
153
+ label: "io.imagestitcher.arsession.poselog",
154
154
  attributes: .concurrent
155
155
  )
156
156
  private static let MAX_POSE_LOG = 600 // ~10 s @ 60Hz
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-image-stitcher",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
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",
@@ -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
- * Normalise a native-side file path into the `file://...` URI form
463
- * that React Native's `<Image>` requires on Android. iOS is lenient,
464
- * but Android rejects bare `/data/...` paths and renders a blank
465
- * Image with no error in the JS layer.
466
- *
467
- * Native code in this lib emits paths in two flavours:
468
- * - useCapture.compressedUri already includes `file://` (it's
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 = ensureFileUri(state.batchKeyframeThumbnailPath!);
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
- // Native side returns a bare `/data/.../foo.jpg` path. Android
712
- // <Image> needs the `file://` scheme to render it; iOS is OK
713
- // either way.
714
- uri = ensureFileUri(photo.path);
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
- const result = await capture.takePhoto();
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
- undefined,
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: ensureFileUri(result.panoramaPath),
900
+ uri: toFileUri(result.panoramaPath),
851
901
  width: result.width,
852
902
  height: result.height,
853
903
  framesRequested: result.framesRequested ?? -1,