react-native-image-stitcher 0.1.0

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 (151) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +21 -0
  4. package/README.md +189 -0
  5. package/RNImageStitcher.podspec +76 -0
  6. package/android/build.gradle +224 -0
  7. package/android/src/main/AndroidManifest.xml +3 -0
  8. package/android/src/main/cpp/CMakeLists.txt +124 -0
  9. package/android/src/main/cpp/image_stitcher_jni.cpp +145 -0
  10. package/android/src/main/cpp/keyframe_gate_jni.cpp +204 -0
  11. package/android/src/main/java/io/imagestitcher/rn/BatchStitcher.kt +426 -0
  12. package/android/src/main/java/io/imagestitcher/rn/IncrementalFirstwinsEngine.kt +960 -0
  13. package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +2371 -0
  14. package/android/src/main/java/io/imagestitcher/rn/KeyframeGate.kt +256 -0
  15. package/android/src/main/java/io/imagestitcher/rn/QualityChecker.kt +167 -0
  16. package/android/src/main/java/io/imagestitcher/rn/RNImageStitcherPackage.kt +39 -0
  17. package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +558 -0
  18. package/android/src/main/java/io/imagestitcher/rn/RNSARCameraViewManager.kt +35 -0
  19. package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +784 -0
  20. package/android/src/main/java/io/imagestitcher/rn/ar/BackgroundRenderer.kt +176 -0
  21. package/android/src/main/java/io/imagestitcher/rn/ar/ShaderUtil.kt +67 -0
  22. package/android/src/main/java/io/imagestitcher/rn/ar/YuvImageConverter.kt +201 -0
  23. package/cpp/ar_frame_pose.h +63 -0
  24. package/cpp/keyframe_gate.cpp +927 -0
  25. package/cpp/keyframe_gate.hpp +240 -0
  26. package/cpp/stitcher.cpp +2207 -0
  27. package/cpp/stitcher.hpp +275 -0
  28. package/dist/ar/useARSession.d.ts +102 -0
  29. package/dist/ar/useARSession.js +133 -0
  30. package/dist/camera/ARCameraView.d.ts +93 -0
  31. package/dist/camera/ARCameraView.js +170 -0
  32. package/dist/camera/Camera.d.ts +134 -0
  33. package/dist/camera/Camera.js +688 -0
  34. package/dist/camera/CameraShutter.d.ts +80 -0
  35. package/dist/camera/CameraShutter.js +237 -0
  36. package/dist/camera/CameraView.d.ts +65 -0
  37. package/dist/camera/CameraView.js +117 -0
  38. package/dist/camera/CaptureControlsBar.d.ts +87 -0
  39. package/dist/camera/CaptureControlsBar.js +82 -0
  40. package/dist/camera/CaptureHeader.d.ts +62 -0
  41. package/dist/camera/CaptureHeader.js +81 -0
  42. package/dist/camera/CapturePreview.d.ts +70 -0
  43. package/dist/camera/CapturePreview.js +188 -0
  44. package/dist/camera/CaptureStatusOverlay.d.ts +75 -0
  45. package/dist/camera/CaptureStatusOverlay.js +326 -0
  46. package/dist/camera/CaptureThumbnailStrip.d.ts +87 -0
  47. package/dist/camera/CaptureThumbnailStrip.js +177 -0
  48. package/dist/camera/IncrementalPanGuide.d.ts +83 -0
  49. package/dist/camera/IncrementalPanGuide.js +267 -0
  50. package/dist/camera/PanoramaBandOverlay.d.ts +107 -0
  51. package/dist/camera/PanoramaBandOverlay.js +399 -0
  52. package/dist/camera/PanoramaConfirmModal.d.ts +57 -0
  53. package/dist/camera/PanoramaConfirmModal.js +128 -0
  54. package/dist/camera/PanoramaGuidance.d.ts +79 -0
  55. package/dist/camera/PanoramaGuidance.js +246 -0
  56. package/dist/camera/PanoramaSettingsModal.d.ts +311 -0
  57. package/dist/camera/PanoramaSettingsModal.js +611 -0
  58. package/dist/camera/ViewportCropOverlay.d.ts +46 -0
  59. package/dist/camera/ViewportCropOverlay.js +67 -0
  60. package/dist/camera/useCapture.d.ts +111 -0
  61. package/dist/camera/useCapture.js +160 -0
  62. package/dist/camera/useDeviceOrientation.d.ts +48 -0
  63. package/dist/camera/useDeviceOrientation.js +131 -0
  64. package/dist/camera/useVideoCapture.d.ts +79 -0
  65. package/dist/camera/useVideoCapture.js +151 -0
  66. package/dist/index.d.ts +26 -0
  67. package/dist/index.js +39 -0
  68. package/dist/quality/normaliseOrientation.d.ts +36 -0
  69. package/dist/quality/normaliseOrientation.js +62 -0
  70. package/dist/quality/runQualityCheck.d.ts +41 -0
  71. package/dist/quality/runQualityCheck.js +98 -0
  72. package/dist/sensors/useIMUTranslationGate.d.ts +70 -0
  73. package/dist/sensors/useIMUTranslationGate.js +235 -0
  74. package/dist/stitching/IncrementalStitcherView.d.ts +41 -0
  75. package/dist/stitching/IncrementalStitcherView.js +157 -0
  76. package/dist/stitching/incremental.d.ts +930 -0
  77. package/dist/stitching/incremental.js +133 -0
  78. package/dist/stitching/stitchFrames.d.ts +55 -0
  79. package/dist/stitching/stitchFrames.js +56 -0
  80. package/dist/stitching/stitchVideo.d.ts +119 -0
  81. package/dist/stitching/stitchVideo.js +57 -0
  82. package/dist/stitching/useIncrementalJSDriver.d.ts +74 -0
  83. package/dist/stitching/useIncrementalJSDriver.js +199 -0
  84. package/dist/stitching/useIncrementalStitcher.d.ts +58 -0
  85. package/dist/stitching/useIncrementalStitcher.js +172 -0
  86. package/dist/types.d.ts +58 -0
  87. package/dist/types.js +15 -0
  88. package/ios/Package.swift +72 -0
  89. package/ios/Sources/RNImageStitcher/ARCameraViewManager.m +33 -0
  90. package/ios/Sources/RNImageStitcher/ARCameraViewManager.swift +40 -0
  91. package/ios/Sources/RNImageStitcher/ARSessionBridge.m +55 -0
  92. package/ios/Sources/RNImageStitcher/ARSessionBridge.swift +149 -0
  93. package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +2727 -0
  94. package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.m +85 -0
  95. package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.swift +625 -0
  96. package/ios/Sources/RNImageStitcher/KeyframeGate.swift +328 -0
  97. package/ios/Sources/RNImageStitcher/KeyframeGateBridge.h +141 -0
  98. package/ios/Sources/RNImageStitcher/KeyframeGateBridge.mm +278 -0
  99. package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.h +473 -0
  100. package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.mm +1326 -0
  101. package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.h +97 -0
  102. package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.mm +296 -0
  103. package/ios/Sources/RNImageStitcher/OpenCVSlitScanStitcher.h +103 -0
  104. package/ios/Sources/RNImageStitcher/OpenCVSlitScanStitcher.mm +3285 -0
  105. package/ios/Sources/RNImageStitcher/OpenCVStitcher.h +238 -0
  106. package/ios/Sources/RNImageStitcher/OpenCVStitcher.mm +1880 -0
  107. package/ios/Sources/RNImageStitcher/QualityChecker.swift +252 -0
  108. package/ios/Sources/RNImageStitcher/QualityCheckerBridge.m +26 -0
  109. package/ios/Sources/RNImageStitcher/QualityCheckerBridge.swift +72 -0
  110. package/ios/Sources/RNImageStitcher/RNSARCameraView.swift +114 -0
  111. package/ios/Sources/RNImageStitcher/RNSARSession.swift +1111 -0
  112. package/ios/Sources/RNImageStitcher/Stitcher.swift +243 -0
  113. package/ios/Sources/RNImageStitcher/StitcherBridge.m +28 -0
  114. package/ios/Sources/RNImageStitcher/StitcherBridge.swift +246 -0
  115. package/package.json +73 -0
  116. package/react-native.config.js +34 -0
  117. package/scripts/opencv-version.txt +1 -0
  118. package/scripts/postinstall-fetch-binaries.js +286 -0
  119. package/src/ar/useARSession.ts +210 -0
  120. package/src/camera/.gitkeep +0 -0
  121. package/src/camera/ARCameraView.tsx +256 -0
  122. package/src/camera/Camera.tsx +1053 -0
  123. package/src/camera/CameraShutter.tsx +292 -0
  124. package/src/camera/CameraView.tsx +157 -0
  125. package/src/camera/CaptureControlsBar.tsx +204 -0
  126. package/src/camera/CaptureHeader.tsx +184 -0
  127. package/src/camera/CapturePreview.tsx +318 -0
  128. package/src/camera/CaptureStatusOverlay.tsx +391 -0
  129. package/src/camera/CaptureThumbnailStrip.tsx +277 -0
  130. package/src/camera/IncrementalPanGuide.tsx +328 -0
  131. package/src/camera/PanoramaBandOverlay.tsx +498 -0
  132. package/src/camera/PanoramaConfirmModal.tsx +206 -0
  133. package/src/camera/PanoramaGuidance.tsx +327 -0
  134. package/src/camera/PanoramaSettingsModal.tsx +1357 -0
  135. package/src/camera/ViewportCropOverlay.tsx +81 -0
  136. package/src/camera/useCapture.ts +279 -0
  137. package/src/camera/useDeviceOrientation.ts +140 -0
  138. package/src/camera/useVideoCapture.ts +236 -0
  139. package/src/index.ts +53 -0
  140. package/src/quality/.gitkeep +0 -0
  141. package/src/quality/normaliseOrientation.ts +79 -0
  142. package/src/quality/runQualityCheck.ts +131 -0
  143. package/src/sensors/useIMUTranslationGate.ts +347 -0
  144. package/src/stitching/.gitkeep +0 -0
  145. package/src/stitching/IncrementalStitcherView.tsx +198 -0
  146. package/src/stitching/incremental.ts +1021 -0
  147. package/src/stitching/stitchFrames.ts +88 -0
  148. package/src/stitching/stitchVideo.ts +153 -0
  149. package/src/stitching/useIncrementalJSDriver.ts +273 -0
  150. package/src/stitching/useIncrementalStitcher.ts +252 -0
  151. package/src/types.ts +78 -0
@@ -0,0 +1,236 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * useVideoCapture — video recording + frame extraction API.
4
+ *
5
+ * Sibling of ``useCapture`` for the "sweep a shelf with a video" flow
6
+ * that feeds the stitcher. The hook's state machine is:
7
+ *
8
+ * idle → recording → stopping → idle (success)
9
+ * idle → recording → idle (recording errored)
10
+ * idle → recording → stopping → idle (user cancelled)
11
+ *
12
+ * API shape — `startRecording` returns a `Promise<VideoFile>` that:
13
+ * - resolves with the recorded file when the user releases the
14
+ * shutter and `stopRecording()` is called (vision-camera fires
15
+ * `onRecordingFinished`),
16
+ * - rejects if vision-camera fires `onRecordingError` at any point
17
+ * (e.g. `<Camera>` was rendered without `video={true}`, disk
18
+ * full mid-recording, permission revoked).
19
+ *
20
+ * Why a single future instead of separate finished/error callbacks?
21
+ * Lets host code use the natural async/await pattern. Earlier we
22
+ * used a callback + a parked resolver in the host screen; that
23
+ * masked start-time errors (the resolver hung forever) and produced
24
+ * confusing cascade errors when `stopRecording` ran against a
25
+ * non-recording camera.
26
+ *
27
+ * Frame extraction lives behind the `extractFrames` method but is
28
+ * deferred to the higher-level `stitchVideo()` SDK API now — host
29
+ * apps shouldn't have to manage their own tmp dir. This shim is
30
+ * kept for parity / future use; it currently throws.
31
+ */
32
+
33
+ import { useCallback, useRef, useState } from 'react';
34
+ import { Camera, type VideoFile } from 'react-native-vision-camera';
35
+
36
+
37
+ export type VideoCaptureState =
38
+ | 'idle'
39
+ | 'recording'
40
+ | 'stopping'
41
+ | 'extracting';
42
+
43
+
44
+ export interface UseVideoCaptureReturn {
45
+ state: VideoCaptureState;
46
+ /**
47
+ * Begin recording on the given camera ref. Returns a Promise that
48
+ * resolves with the resulting `VideoFile` once `stopRecording()`
49
+ * has been called and vision-camera writes the file to disk, or
50
+ * rejects on any recording error.
51
+ *
52
+ * Callers who only need fire-and-forget can ignore the returned
53
+ * promise; the hook still tracks state internally.
54
+ */
55
+ startRecording: (
56
+ cameraRef: React.RefObject<Camera | null>,
57
+ ) => Promise<VideoFile>;
58
+ /**
59
+ * Tell vision-camera to stop the active recording. If no recording
60
+ * is in progress (e.g. it errored at start) this is a safe no-op.
61
+ * The recorded file flows back through the promise returned by
62
+ * `startRecording`, NOT through this method's return.
63
+ */
64
+ stopRecording: (
65
+ cameraRef: React.RefObject<Camera | null>,
66
+ ) => Promise<void>;
67
+ /**
68
+ * Cancel the active recording without delivering the file. The
69
+ * promise from `startRecording` will reject with a cancellation
70
+ * error so awaiters unblock instead of receiving a stale file.
71
+ */
72
+ cancelRecording: (
73
+ cameraRef: React.RefObject<Camera | null>,
74
+ ) => Promise<void>;
75
+ /**
76
+ * Decode an mp4 into N evenly-spaced still frames, written to
77
+ * ``outputDir``. Throws NOT_IMPLEMENTED — call `stitchVideo` from
78
+ * the SDK index instead, which combines extract + stitch in one
79
+ * native bridge call so the JS thread never has to round-trip
80
+ * frame paths.
81
+ */
82
+ extractFrames: (opts: ExtractFramesOptions) => Promise<ExtractFramesResult>;
83
+ }
84
+
85
+
86
+ export interface ExtractFramesOptions {
87
+ videoPath: string;
88
+ outputDir: string;
89
+ frameCount: number;
90
+ /** JPEG quality [0-100]. Defaults to 85. */
91
+ quality?: number;
92
+ }
93
+
94
+
95
+ export interface ExtractFramesResult {
96
+ framePaths: string[];
97
+ durationMs: number;
98
+ }
99
+
100
+
101
+ export function useVideoCapture(): UseVideoCaptureReturn {
102
+ const [state, setState] = useState<VideoCaptureState>('idle');
103
+
104
+ /**
105
+ * The active recording's resolve/reject handles. Set when
106
+ * `startRecording` is called; cleared when vision-camera fires
107
+ * `onRecordingFinished` / `onRecordingError`, or when the host
108
+ * calls `cancelRecording`.
109
+ *
110
+ * Stored in a ref (not state) because vision-camera's callbacks
111
+ * fire outside React's render cycle and need synchronous access
112
+ * to the latest handles.
113
+ */
114
+ const recordingFutureRef = useRef<{
115
+ resolve: (video: VideoFile) => void;
116
+ reject: (err: unknown) => void;
117
+ } | null>(null);
118
+
119
+ const startRecording = useCallback(
120
+ async (cameraRef: React.RefObject<Camera | null>): Promise<VideoFile> => {
121
+ if (!cameraRef.current) {
122
+ throw new Error('useVideoCapture.startRecording: cameraRef is null');
123
+ }
124
+ if (recordingFutureRef.current !== null) {
125
+ throw new Error(
126
+ 'useVideoCapture.startRecording: a recording is already in progress',
127
+ );
128
+ }
129
+
130
+ // Brief settle before kicking off a fresh recording.
131
+ // vision-camera's AVCaptureSession needs ~100 ms after a
132
+ // previous stopRecording completes before it can cleanly
133
+ // start a new one — without this, rapid record→stop→record
134
+ // cycles (typical when the user does a panorama, then another
135
+ // panorama right after) can crash the session.
136
+ await new Promise<void>((r) => setTimeout(r, 150));
137
+
138
+ return new Promise<VideoFile>((resolve, reject) => {
139
+ recordingFutureRef.current = { resolve, reject };
140
+ setState('recording');
141
+ cameraRef.current!.startRecording({
142
+ onRecordingFinished: (video) => {
143
+ const future = recordingFutureRef.current;
144
+ recordingFutureRef.current = null;
145
+ setState('idle');
146
+ // If the future was cleared (cancelled), drop the file
147
+ // on the floor. vision-camera still wrote it to disk;
148
+ // that's a known cleanup gap for a future iteration.
149
+ future?.resolve(video);
150
+ },
151
+ onRecordingError: (err) => {
152
+ const future = recordingFutureRef.current;
153
+ recordingFutureRef.current = null;
154
+ setState('idle');
155
+ // eslint-disable-next-line no-console
156
+ console.error('[useVideoCapture] recording error', err);
157
+ future?.reject(err);
158
+ },
159
+ });
160
+ });
161
+ },
162
+ [],
163
+ );
164
+
165
+ const stopRecording = useCallback(
166
+ async (cameraRef: React.RefObject<Camera | null>): Promise<void> => {
167
+ // No-op if there's nothing to stop — protects against the
168
+ // cascade where startRecording errored synchronously and the
169
+ // host's release handler still calls stop.
170
+ if (recordingFutureRef.current === null) return;
171
+ if (!cameraRef.current) return;
172
+ setState('stopping');
173
+ try {
174
+ await cameraRef.current.stopRecording();
175
+ } catch (err) {
176
+ // The native call can throw "no-recording-in-progress" if
177
+ // the recording was already finalised by an error path.
178
+ // The future has its own error handling; we just absorb so
179
+ // the host's await doesn't see a confusing secondary error.
180
+ // eslint-disable-next-line no-console
181
+ console.warn('[useVideoCapture] stopRecording threw', err);
182
+ }
183
+ },
184
+ [],
185
+ );
186
+
187
+ const cancelRecording = useCallback(
188
+ async (cameraRef: React.RefObject<Camera | null>): Promise<void> => {
189
+ const future = recordingFutureRef.current;
190
+ // Clear FIRST so the eventual onRecordingFinished/Error
191
+ // callback no-ops on a null ref instead of fulfilling a
192
+ // promise the caller has already moved past.
193
+ recordingFutureRef.current = null;
194
+ if (future) {
195
+ future.reject(new Error('useVideoCapture: recording cancelled'));
196
+ }
197
+ if (!cameraRef.current) {
198
+ setState('idle');
199
+ return;
200
+ }
201
+ setState('stopping');
202
+ try {
203
+ await cameraRef.current.stopRecording();
204
+ } catch {
205
+ // Expected when no recording is in flight.
206
+ } finally {
207
+ setState('idle');
208
+ }
209
+ },
210
+ [],
211
+ );
212
+
213
+ const extractFrames = useCallback(
214
+ async (_opts: ExtractFramesOptions): Promise<ExtractFramesResult> => {
215
+ setState('extracting');
216
+ try {
217
+ throw new Error(
218
+ '[react-native-image-stitcher] useVideoCapture.extractFrames is not '
219
+ + 'available — use `stitchVideo()` from the SDK index, which '
220
+ + 'combines extract + stitch in a single native call.',
221
+ );
222
+ } finally {
223
+ setState('idle');
224
+ }
225
+ },
226
+ [],
227
+ );
228
+
229
+ return {
230
+ state,
231
+ startRecording,
232
+ stopRecording,
233
+ cancelRecording,
234
+ extractFrames,
235
+ };
236
+ }
package/src/index.ts ADDED
@@ -0,0 +1,53 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * react-native-image-stitcher — public API surface.
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.
10
+ *
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
+ *
15
+ * 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
+ */
21
+
22
+ // ── The main component ────────────────────────────────────────────────────
23
+ export { Camera, CameraError } from './camera/Camera';
24
+ export type {
25
+ CameraProps,
26
+ CameraCaptureResult,
27
+ CameraErrorCode,
28
+ CaptureSource,
29
+ CameraLens,
30
+ StitchMode,
31
+ Blender,
32
+ SeamFinder,
33
+ Warper,
34
+ FramesDroppedInfo,
35
+ } from './camera/Camera';
36
+
37
+ // ── AR foundation (public per design doc) ─────────────────────────────────
38
+ // Hosts that want raw AR pose access (e.g., to build their own
39
+ // measurement/detection on top) consume these directly.
40
+ export { useARSession, ARTrackingState } from './ar/useARSession';
41
+ export type {
42
+ UseARSessionReturn,
43
+ FramePose,
44
+ } from './ar/useARSession';
45
+
46
+ // ── IMU translation gate (public per design doc R5) ───────────────────────
47
+ // Hosts running their own non-AR capture flow can reuse this hook to
48
+ // get the same gating logic <Camera> uses internally.
49
+ export { useIMUTranslationGate } from './sensors/useIMUTranslationGate';
50
+ export type {
51
+ UseIMUTranslationGateOptions,
52
+ UseIMUTranslationGateReturn,
53
+ } from './sensors/useIMUTranslationGate';
File without changes
@@ -0,0 +1,79 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * normaliseOrientation — bake EXIF rotation into a JPEG's pixels.
4
+ *
5
+ * vision-camera writes photos with the camera sensor's native
6
+ * landscape pixels and an EXIF Orientation tag describing how to
7
+ * rotate them for display. Most consumers (iOS UIImage, RN's
8
+ * `<Image>`) honour the tag — but enough don't (Sentry breadcrumbs,
9
+ * share sheets, the cv::Stitcher itself, third-party image
10
+ * pipelines) that "what's on disk" diverges from "what the user
11
+ * sees" unless we eagerly normalise.
12
+ *
13
+ * This helper round-trips the file through the SDK's native
14
+ * stitcher module, which decodes the JPEG with EXIF rotation
15
+ * applied and re-encodes a clean JPEG with no orientation
16
+ * metadata. Idempotent on already-normalised files.
17
+ */
18
+
19
+ import { NativeModules, Platform } from 'react-native';
20
+
21
+
22
+ export interface NormaliseOrientationResult {
23
+ /** Image width in pixels AFTER rotation has been applied. */
24
+ width: number;
25
+ /** Image height in pixels AFTER rotation. */
26
+ height: number;
27
+ }
28
+
29
+
30
+ /**
31
+ * Bake the EXIF rotation of `imagePath` into pixels in-place.
32
+ *
33
+ * Returns the post-rotation dimensions so the caller can update
34
+ * its own width/height fields. No-op on platforms that don't
35
+ * have the native module yet (Android until Phase 3) — falls back
36
+ * to the input shape so callers don't have to special-case
37
+ * platform availability.
38
+ */
39
+ export async function normaliseOrientation(
40
+ imagePath: string,
41
+ fallback?: { width: number; height: number },
42
+ ): Promise<NormaliseOrientationResult> {
43
+ const native: unknown =
44
+ (NativeModules as Record<string, unknown>)['BatchStitcher'];
45
+ const fn =
46
+ native
47
+ && typeof native === 'object'
48
+ && typeof (native as { normaliseOrientation?: unknown }).normaliseOrientation === 'function'
49
+ ? (native as {
50
+ normaliseOrientation: (
51
+ o: { imagePath: string },
52
+ ) => Promise<NormaliseOrientationResult>;
53
+ }).normaliseOrientation
54
+ : null;
55
+
56
+ if (!fn) {
57
+ // Native module not registered (typically Android in current
58
+ // builds). Skip normalisation and report the caller's
59
+ // fallback dimensions if provided, otherwise zeroes — keeps
60
+ // the API total without forcing every caller to wrap a
61
+ // try/catch.
62
+ if (fallback) return fallback;
63
+ // eslint-disable-next-line no-console
64
+ console.warn(
65
+ `[capture-sdk] normaliseOrientation: native module not available on ${Platform.OS}. `
66
+ + 'Photo orientation may render incorrectly until the native module is registered.',
67
+ );
68
+ return { width: 0, height: 0 };
69
+ }
70
+
71
+ try {
72
+ return await fn({ imagePath });
73
+ } catch (err) {
74
+ // eslint-disable-next-line no-console
75
+ console.warn('[capture-sdk] normaliseOrientation failed', err);
76
+ if (fallback) return fallback;
77
+ throw err;
78
+ }
79
+ }
@@ -0,0 +1,131 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * runQualityCheck — public entry point for the SDK's blur + brightness
4
+ * quality gate. Delegates to the native module
5
+ * `RNImageStitcherQualityChecker` when registered (iOS today via
6
+ * `ios/Sources/RNImageStitcher/QualityChecker.swift`, Android in
7
+ * Phase 3); falls back to a conservative pass-through shim when the
8
+ * native module is absent so dev / Jest runs don't crash on a missing
9
+ * NativeModules entry.
10
+ *
11
+ * The shim NEVER fails an image — false negatives in production are
12
+ * worse than missing data. The native path is the only branch that
13
+ * can return `passed=false`.
14
+ */
15
+
16
+ import { NativeModules, Platform } from 'react-native';
17
+
18
+ import type { QualityIssue, QualityReport, QualityThresholds } from '../types';
19
+
20
+
21
+ let warnedOnce = false;
22
+
23
+
24
+ /**
25
+ * Numeric scores produced by the native module. The bridge resolves
26
+ * with `{ blurScore, brightnessScore }` — issues are computed in JS
27
+ * so the same threshold logic applies on iOS + Android even if a
28
+ * platform's native impl evolves independently.
29
+ */
30
+ interface NativeQualityScores {
31
+ blurScore: number;
32
+ brightnessScore: number;
33
+ }
34
+
35
+
36
+ /**
37
+ * Analyse an image file and return a quality report.
38
+ *
39
+ * @param imagePath Filesystem path to the image (with or without
40
+ * `file://` prefix — the native module strips it).
41
+ * @param thresholds Cut-offs the report scores against.
42
+ */
43
+ export async function runQualityCheck(
44
+ imagePath: string,
45
+ thresholds: QualityThresholds,
46
+ ): Promise<QualityReport> {
47
+ const native = (NativeModules as Record<string, unknown>)['RNImageStitcherQualityChecker'];
48
+
49
+ // Native path: registered + has the bridged `measure` method.
50
+ if (
51
+ native
52
+ && typeof native === 'object'
53
+ && typeof (native as { measure?: unknown }).measure === 'function'
54
+ ) {
55
+ const scores: NativeQualityScores =
56
+ await (native as { measure: (path: string) => Promise<NativeQualityScores> })
57
+ .measure(imagePath);
58
+ return scoreToReport(scores, thresholds);
59
+ }
60
+
61
+ // Shim fallback — never reached when the SDK's native module is linked
62
+ // into the host app correctly. Surfaces a one-time warning in dev so
63
+ // misconfiguration is loud rather than silent.
64
+ if (!warnedOnce && __DEV__) {
65
+ // eslint-disable-next-line no-console
66
+ console.warn(
67
+ '[react-native-image-stitcher] QualityChecker native module not '
68
+ + `found on ${Platform.OS}; falling back to optimistic shim. Check `
69
+ + 'autolinking + a clean `pod install` (iOS) / `gradle clean` (Android).',
70
+ );
71
+ warnedOnce = true;
72
+ }
73
+ void thresholds;
74
+ return {
75
+ passed: true,
76
+ blurScore: Number.POSITIVE_INFINITY,
77
+ brightnessScore: 128,
78
+ issues: [],
79
+ };
80
+ }
81
+
82
+
83
+ /**
84
+ * Apply thresholds to native scores → produce the report shape the JS
85
+ * surface promises. Pure function so it's trivially unit-testable.
86
+ *
87
+ * Exported for tests; not part of the SDK's public API.
88
+ */
89
+ export function scoreToReport(
90
+ scores: NativeQualityScores,
91
+ thresholds: QualityThresholds,
92
+ ): QualityReport {
93
+ const issues: QualityIssue[] = [];
94
+
95
+ if (scores.blurScore < thresholds.minBlurScore) {
96
+ issues.push({
97
+ type: 'blur',
98
+ message:
99
+ `Image is too blurry (Laplacian variance ${scores.blurScore.toFixed(1)} `
100
+ + `< ${thresholds.minBlurScore}). Hold the camera steady and retry.`,
101
+ severity: 'error',
102
+ });
103
+ }
104
+ if (scores.brightnessScore < thresholds.minBrightness) {
105
+ issues.push({
106
+ type: 'brightness_low',
107
+ message:
108
+ `Image is too dark (mean luminance ${scores.brightnessScore.toFixed(0)} `
109
+ + `< ${thresholds.minBrightness}). Add light or move closer to a lit area.`,
110
+ severity: 'warning',
111
+ });
112
+ } else if (scores.brightnessScore > thresholds.maxBrightness) {
113
+ issues.push({
114
+ type: 'brightness_high',
115
+ message:
116
+ `Image is overexposed (mean luminance ${scores.brightnessScore.toFixed(0)} `
117
+ + `> ${thresholds.maxBrightness}). Reduce light or move out of direct sunlight.`,
118
+ severity: 'warning',
119
+ });
120
+ }
121
+
122
+ return {
123
+ // `passed` is the strict gate — only `error`-severity issues block.
124
+ // Brightness warnings are advisory; SOS is still computable from a
125
+ // dim or bright photo, but a blurry one isn't.
126
+ passed: issues.every((i) => i.severity !== 'error'),
127
+ blurScore: scores.blurScore,
128
+ brightnessScore: scores.brightnessScore,
129
+ issues,
130
+ };
131
+ }