react-native-image-stitcher 0.1.0 → 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.
@@ -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,
@@ -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
- const orientedPhoto: PhotoFile = {
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
@@ -2,24 +2,28 @@
2
2
  /**
3
3
  * react-native-image-stitcher — public API surface.
4
4
  *
5
- * Single component (`<Camera>`) + supporting types + the two public
6
- * hooks the design doc calls out (`useARSession`, `useIMUTranslationGate`).
7
- * Everything else (internal sub-components, drivers, bridges) is
8
- * deliberately NOT re-exported so the v0.1.0 → 1.0 stability window
9
- * doesn't lock us into an inflated public surface.
5
+ * Two layers:
6
+ * 1. The high-level `<Camera>` component for hosts that want a
7
+ * drop-in capture experience. Tap = photo, hold + pan + release
8
+ * = panorama. Single mount, all UI included.
9
+ * 2. The lower-level building blocks (views, hooks, stitching
10
+ * engine bindings, settings modal, status overlays) for hosts
11
+ * that want to compose their own capture UX while reusing the
12
+ * battle-tested camera and stitching internals.
10
13
  *
11
- * If you need access to something that used to be exported and isn't
12
- * now, please open an issue describing the use-case before reaching
13
- * into the package internals.
14
+ * Layer 1 (`<Camera>`) is the recommended starting point. Reach for
15
+ * layer 2 when the high-level component doesn't give you enough
16
+ * control — e.g., the private `retailens-camera-sdk` adds
17
+ * measurement + packet detection on top of these building blocks.
14
18
  *
15
19
  * Public/private split: this lib is the open-source foundation. The
16
- * `retailens-camera-sdk` package depends on this lib and adds
17
- * RetaiLens-specific features (measurement, packet detection, etc.)
18
- * on top. Consumers wanting those features install
19
- * `retailens-camera-sdk` instead.
20
+ * `retailens-camera-sdk` package depends on this lib (peer dep) and
21
+ * adds RetaiLens-specific features on top.
20
22
  */
21
23
 
22
- // ── The main component ────────────────────────────────────────────────────
24
+ // ─────────────────────────────────────────────────────────────────────
25
+ // Layer 1 — the high-level <Camera> component
26
+ // ─────────────────────────────────────────────────────────────────────
23
27
  export { Camera, CameraError } from './camera/Camera';
24
28
  export type {
25
29
  CameraProps,
@@ -34,7 +38,9 @@ export type {
34
38
  FramesDroppedInfo,
35
39
  } from './camera/Camera';
36
40
 
37
- // ── AR foundation (public per design doc) ─────────────────────────────────
41
+ // ─────────────────────────────────────────────────────────────────────
42
+ // AR foundation (public since 0.1.0)
43
+ // ─────────────────────────────────────────────────────────────────────
38
44
  // Hosts that want raw AR pose access (e.g., to build their own
39
45
  // measurement/detection on top) consume these directly.
40
46
  export { useARSession, ARTrackingState } from './ar/useARSession';
@@ -43,11 +49,77 @@ export type {
43
49
  FramePose,
44
50
  } from './ar/useARSession';
45
51
 
46
- // ── IMU translation gate (public per design doc R5) ───────────────────────
52
+ // ─────────────────────────────────────────────────────────────────────
53
+ // IMU translation gate (public since 0.1.0)
54
+ // ─────────────────────────────────────────────────────────────────────
47
55
  // Hosts running their own non-AR capture flow can reuse this hook to
48
- // get the same gating logic <Camera> uses internally.
56
+ // get the same translation-budget gating logic <Camera> uses internally.
49
57
  export { useIMUTranslationGate } from './sensors/useIMUTranslationGate';
50
58
  export type {
51
59
  UseIMUTranslationGateOptions,
52
60
  UseIMUTranslationGateReturn,
53
61
  } from './sensors/useIMUTranslationGate';
62
+
63
+ // ═════════════════════════════════════════════════════════════════════
64
+ // Layer 2 — composable building blocks (added in 0.1.1)
65
+ // ═════════════════════════════════════════════════════════════════════
66
+
67
+ // ── Camera view components ────────────────────────────────────────────
68
+ // Drop-in replacements for vision-camera's raw <Camera> (non-AR) and a
69
+ // parallel ARKit/ARCore-backed view (AR). Use these when you need to
70
+ // hand-compose your capture UI instead of mounting <Camera>.
71
+ export { ARCameraView } from './camera/ARCameraView';
72
+ export type { ARCameraViewHandle, ARCameraViewProps } from './camera/ARCameraView';
73
+ export { CameraView } from './camera/CameraView';
74
+ export type { CameraViewProps } from './camera/CameraView';
75
+
76
+ // ── UI components ─────────────────────────────────────────────────────
77
+ // Presentational pieces of the standard capture screen. Each is a
78
+ // pure component; the host wires the props.
79
+ export { CaptureHeader } from './camera/CaptureHeader';
80
+ export { CaptureControlsBar } from './camera/CaptureControlsBar';
81
+ export { CapturePreview } from './camera/CapturePreview';
82
+ export type { CapturePreviewAction } from './camera/CapturePreview';
83
+ export { CaptureStatusOverlay } from './camera/CaptureStatusOverlay';
84
+ export type { CaptureStatusPhase } from './camera/CaptureStatusOverlay';
85
+ export { CaptureThumbnailStrip } from './camera/CaptureThumbnailStrip';
86
+ export type { CaptureThumbnailItem } from './camera/CaptureThumbnailStrip';
87
+ export { IncrementalPanGuide } from './camera/IncrementalPanGuide';
88
+ export { PanoramaBandOverlay } from './camera/PanoramaBandOverlay';
89
+ export { PanoramaGuidance } from './camera/PanoramaGuidance';
90
+ export {
91
+ PanoramaSettingsModal,
92
+ DEFAULT_PANORAMA_SETTINGS,
93
+ } from './camera/PanoramaSettingsModal';
94
+ export type { PanoramaSettings } from './camera/PanoramaSettingsModal';
95
+ export { ViewportCropOverlay } from './camera/ViewportCropOverlay';
96
+
97
+ // ── Capture hooks ─────────────────────────────────────────────────────
98
+ // vision-camera wrappers (useCapture / useVideoCapture) + a
99
+ // device-orientation reader that works under iOS portrait-lock.
100
+ export { useCapture } from './camera/useCapture';
101
+ export type { TakePhotoCallOptions } from './camera/useCapture';
102
+ export { useVideoCapture } from './camera/useVideoCapture';
103
+ export { useDeviceOrientation } from './camera/useDeviceOrientation';
104
+
105
+ // ── Incremental stitching engine ──────────────────────────────────────
106
+ // JS bindings around the native `IncrementalStitcher` module. Use
107
+ // these when you need finer control than <Camera>'s built-in
108
+ // hold-to-pan flow (e.g., feeding frames from a custom source, or
109
+ // reading the engine's running state to drive a custom UI).
110
+ export {
111
+ IncrementalOutcome,
112
+ incrementalStitcherIsAvailable,
113
+ subscribeIncrementalState,
114
+ getIncrementalNativeModule,
115
+ cleanupOldKeyframes,
116
+ } from './stitching/incremental';
117
+ export type { IncrementalState } from './stitching/incremental';
118
+ export { useIncrementalStitcher } from './stitching/useIncrementalStitcher';
119
+ export { useIncrementalJSDriver } from './stitching/useIncrementalJSDriver';
120
+
121
+ // ── Batch stitching ───────────────────────────────────────────────────
122
+ // Feed a video file straight to OpenCV's cv::Stitcher, bypassing the
123
+ // incremental pipeline. Useful when you have content captured
124
+ // outside the SDK and just want a panorama out.
125
+ export { stitchVideo } from './stitching/stitchVideo';
@@ -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
+ }