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.
package/CHANGELOG.md CHANGED
@@ -16,7 +16,121 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
16
16
 
17
17
  ## [Unreleased]
18
18
 
19
- ## [0.1.0] — TBD
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
+
95
+ ## [0.1.1] — 2026-05-20
96
+
97
+ ### Added
98
+
99
+ - **Layer 2 building blocks now public.** The lower-level views,
100
+ hooks, and stitching-engine bindings that previously lived behind
101
+ the `<Camera>` wrapper are now exported from the package root.
102
+ Use these when `<Camera>` doesn't give you enough control — e.g.,
103
+ when you're hand-composing your own capture screen on top of the
104
+ same proven primitives. Full list:
105
+ - Views: `ARCameraView`, `CameraView` (+ their handle/prop types).
106
+ - UI components: `CaptureHeader`, `CaptureControlsBar`,
107
+ `CapturePreview`, `CaptureStatusOverlay`, `CaptureThumbnailStrip`,
108
+ `IncrementalPanGuide`, `PanoramaBandOverlay`, `PanoramaGuidance`,
109
+ `PanoramaSettingsModal` (+ `DEFAULT_PANORAMA_SETTINGS` constant +
110
+ `PanoramaSettings` type), `ViewportCropOverlay`.
111
+ - Hooks: `useCapture`, `useVideoCapture`, `useDeviceOrientation`,
112
+ `useIncrementalStitcher`, `useIncrementalJSDriver`.
113
+ - Engine: `IncrementalOutcome`, `incrementalStitcherIsAvailable`,
114
+ `subscribeIncrementalState`, `getIncrementalNativeModule`,
115
+ `cleanupOldKeyframes`, `IncrementalState` (type).
116
+ - Batch stitching: `stitchVideo`.
117
+ - The 0.1.0 → 1.0 stability gate still applies — the goal of
118
+ surfacing layer 2 is to support advanced consumers (e.g.,
119
+ `retailens-camera-sdk`) without forcing them to deep-import
120
+ package internals. These are likely to keep their shape through
121
+ 1.0, but the contract is not formally stable until then.
122
+
123
+ ### Changed
124
+
125
+ - README now documents both layers and recommends `<Camera>` as the
126
+ default starting point.
127
+
128
+ ### Fixed
129
+
130
+ (No bug fixes in this release — see 0.1.0 for the device-verified
131
+ camera lifecycle fixes that shipped with the initial release.)
132
+
133
+ ## [0.1.0] — 2026-05-20
20
134
 
21
135
  First public release.
22
136
 
@@ -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
- # public_header_files intentionally omitted React Native's
41
- # @objc(...) dispatch doesn't need umbrella headers, and exposing
42
- # all OpenCV*.h headers to consumers locks us into supporting
43
- # internal Obj-C++ classes as public API. See CHANGELOG v0.1.0.
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
- outputPathOpt
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) —
@@ -29,6 +29,7 @@ class RNImageStitcherPackage : ReactPackage {
29
29
  BatchStitcher(reactContext),
30
30
  RNSARSession(reactContext),
31
31
  IncrementalStitcher(reactContext),
32
+ FileBridge(reactContext),
32
33
  )
33
34
 
34
35
  override fun createViewManagers(
@@ -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;
@@ -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
- * Normalise a native-side file path into the `file://...` URI form
254
- * that React Native's `<Image>` requires on Android. iOS is lenient,
255
- * but Android rejects bare `/data/...` paths and renders a blank
256
- * Image with no error in the JS layer.
257
- *
258
- * Native code in this lib emits paths in two flavours:
259
- * - useCapture.compressedUri already includes `file://` (it's
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 = ensureFileUri(state.batchKeyframeThumbnailPath);
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
- // Native side returns a bare `/data/.../foo.jpg` path. Android
460
- // <Image> needs the `file://` scheme to render it; iOS is OK
461
- // either way.
462
- uri = ensureFileUri(photo.path);
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
- const result = await capture.takePhoto();
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
- const result = await incremental.finalize(undefined, 90, deviceOrientation);
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: ensureFileUri(result.panoramaPath),
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
- const orientedPhoto = {
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
@@ -1,21 +1,23 @@
1
1
  /**
2
2
  * react-native-image-stitcher — public API surface.
3
3
  *
4
- * Single component (`<Camera>`) + supporting types + the two public
5
- * hooks the design doc calls out (`useARSession`, `useIMUTranslationGate`).
6
- * Everything else (internal sub-components, drivers, bridges) is
7
- * deliberately NOT re-exported so the v0.1.0 → 1.0 stability window
8
- * doesn't lock us into an inflated public surface.
4
+ * Two layers:
5
+ * 1. The high-level `<Camera>` component for hosts that want a
6
+ * drop-in capture experience. Tap = photo, hold + pan + release
7
+ * = panorama. Single mount, all UI included.
8
+ * 2. The lower-level building blocks (views, hooks, stitching
9
+ * engine bindings, settings modal, status overlays) for hosts
10
+ * that want to compose their own capture UX while reusing the
11
+ * battle-tested camera and stitching internals.
9
12
  *
10
- * If you need access to something that used to be exported and isn't
11
- * now, please open an issue describing the use-case before reaching
12
- * into the package internals.
13
+ * Layer 1 (`<Camera>`) is the recommended starting point. Reach for
14
+ * layer 2 when the high-level component doesn't give you enough
15
+ * control — e.g., the private `retailens-camera-sdk` adds
16
+ * measurement + packet detection on top of these building blocks.
13
17
  *
14
18
  * Public/private split: this lib is the open-source foundation. The
15
- * `retailens-camera-sdk` package depends on this lib and adds
16
- * RetaiLens-specific features (measurement, packet detection, etc.)
17
- * on top. Consumers wanting those features install
18
- * `retailens-camera-sdk` instead.
19
+ * `retailens-camera-sdk` package depends on this lib (peer dep) and
20
+ * adds RetaiLens-specific features on top.
19
21
  */
20
22
  export { Camera, CameraError } from './camera/Camera';
21
23
  export type { CameraProps, CameraCaptureResult, CameraErrorCode, CaptureSource, CameraLens, StitchMode, Blender, SeamFinder, Warper, FramesDroppedInfo, } from './camera/Camera';
@@ -23,4 +25,31 @@ export { useARSession, ARTrackingState } from './ar/useARSession';
23
25
  export type { UseARSessionReturn, FramePose, } from './ar/useARSession';
24
26
  export { useIMUTranslationGate } from './sensors/useIMUTranslationGate';
25
27
  export type { UseIMUTranslationGateOptions, UseIMUTranslationGateReturn, } from './sensors/useIMUTranslationGate';
28
+ export { ARCameraView } from './camera/ARCameraView';
29
+ export type { ARCameraViewHandle, ARCameraViewProps } from './camera/ARCameraView';
30
+ export { CameraView } from './camera/CameraView';
31
+ export type { CameraViewProps } from './camera/CameraView';
32
+ export { CaptureHeader } from './camera/CaptureHeader';
33
+ export { CaptureControlsBar } from './camera/CaptureControlsBar';
34
+ export { CapturePreview } from './camera/CapturePreview';
35
+ export type { CapturePreviewAction } from './camera/CapturePreview';
36
+ export { CaptureStatusOverlay } from './camera/CaptureStatusOverlay';
37
+ export type { CaptureStatusPhase } from './camera/CaptureStatusOverlay';
38
+ export { CaptureThumbnailStrip } from './camera/CaptureThumbnailStrip';
39
+ export type { CaptureThumbnailItem } from './camera/CaptureThumbnailStrip';
40
+ export { IncrementalPanGuide } from './camera/IncrementalPanGuide';
41
+ export { PanoramaBandOverlay } from './camera/PanoramaBandOverlay';
42
+ export { PanoramaGuidance } from './camera/PanoramaGuidance';
43
+ export { PanoramaSettingsModal, DEFAULT_PANORAMA_SETTINGS, } from './camera/PanoramaSettingsModal';
44
+ export type { PanoramaSettings } from './camera/PanoramaSettingsModal';
45
+ export { ViewportCropOverlay } from './camera/ViewportCropOverlay';
46
+ export { useCapture } from './camera/useCapture';
47
+ export type { TakePhotoCallOptions } from './camera/useCapture';
48
+ export { useVideoCapture } from './camera/useVideoCapture';
49
+ export { useDeviceOrientation } from './camera/useDeviceOrientation';
50
+ export { IncrementalOutcome, incrementalStitcherIsAvailable, subscribeIncrementalState, getIncrementalNativeModule, cleanupOldKeyframes, } from './stitching/incremental';
51
+ export type { IncrementalState } from './stitching/incremental';
52
+ export { useIncrementalStitcher } from './stitching/useIncrementalStitcher';
53
+ export { useIncrementalJSDriver } from './stitching/useIncrementalJSDriver';
54
+ export { stitchVideo } from './stitching/stitchVideo';
26
55
  //# sourceMappingURL=index.d.ts.map