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,88 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * stitchFrames — video / frame stitching API.
4
+ *
5
+ * Implementation status (Phase 2 of #8):
6
+ * - iOS: Swift native module that vendors upstream OpenCV's iOS
7
+ * framework and calls `cv::Stitcher::SCANS` mode (designed for
8
+ * translational shelf captures). Lives in
9
+ * `retailens-capture-sdk/ios/Sources/RNImageStitcher/`.
10
+ * - Android: deferred to Phase 3 — same OpenCV surface, different
11
+ * build (NDK + Gradle). Until that lands, Android calls hit the
12
+ * `StitchNotImplementedError` path below.
13
+ *
14
+ * Why fail loudly instead of falling back to JS?
15
+ * The cloud-sync pipeline depends on a stitched panorama being
16
+ * present. Silently producing a broken or single-frame "panorama"
17
+ * would corrupt downstream SOS computation. Hard-failing here lets
18
+ * the host app surface the unsupported-platform error to the user
19
+ * immediately rather than discovering it on the server hours later.
20
+ */
21
+
22
+ import { NativeModules, Platform } from 'react-native';
23
+
24
+
25
+ export interface StitchFramesOptions {
26
+ /**
27
+ * Absolute paths to the input image files in capture order.
28
+ * Must share a camera + focal length (we don't blend across sources).
29
+ */
30
+ framePaths: string[];
31
+ /**
32
+ * Output path for the stitched image (JPEG). Host app chooses the
33
+ * location (tmp vs. cache vs. Documents).
34
+ */
35
+ outputPath: string;
36
+ /** JPEG quality [0-100]. Default 85. */
37
+ quality?: number;
38
+ }
39
+
40
+
41
+ export interface StitchFramesResult {
42
+ /** Absolute path to the stitched image on disk. */
43
+ outputPath: string;
44
+ /** Pixel dimensions of the stitched image. */
45
+ width: number;
46
+ height: number;
47
+ /** Wall-clock ms the stitcher took (host apps log this for perf tracking). */
48
+ durationMs: number;
49
+ }
50
+
51
+
52
+ /**
53
+ * Stitch ``framePaths`` into a single panoramic image.
54
+ *
55
+ * Throws ``StitchNotImplementedError`` until the native module ships.
56
+ * Callers should catch that specific error and fall back gracefully
57
+ * (e.g. surface a "single-frame mode only" banner in the UI).
58
+ */
59
+ export async function stitchFrames(
60
+ options: StitchFramesOptions,
61
+ ): Promise<StitchFramesResult> {
62
+ // Look for the native module by its canonical name so we can flip
63
+ // this function to "implemented" simply by registering the module
64
+ // in AppDelegate / MainApplication.
65
+ const native: unknown =
66
+ (NativeModules as Record<string, unknown>)['BatchStitcher'];
67
+ if (native && typeof native === 'object' && 'stitch' in (native as object)) {
68
+ const fn = (native as { stitch: (o: StitchFramesOptions) => Promise<StitchFramesResult> }).stitch;
69
+ return fn(options);
70
+ }
71
+
72
+ throw new StitchNotImplementedError(
73
+ `stitchFrames is not yet implemented on ${Platform.OS}. `
74
+ + 'The react-native-image-stitcher native stitcher module is expected '
75
+ + 'but not registered — the JS shim is throwing by design so the '
76
+ + 'host app can fall back to single-frame mode rather than ship '
77
+ + 'broken panoramas.',
78
+ );
79
+ }
80
+
81
+
82
+ export class StitchNotImplementedError extends Error {
83
+ public readonly code = 'STITCH_NOT_IMPLEMENTED';
84
+ constructor(message: string) {
85
+ super(message);
86
+ this.name = 'StitchNotImplementedError';
87
+ }
88
+ }
@@ -0,0 +1,153 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * stitchVideo — end-to-end "video → panorama" API.
4
+ *
5
+ * The host app records video while the user holds the shutter; on
6
+ * release we hand the video file path to this function and it
7
+ * returns a single stitched panoramic JPEG. Internally:
8
+ *
9
+ * 1. Native side extracts N evenly-spaced frames from the video
10
+ * (AVAssetImageGenerator on iOS, MediaMetadataRetriever on
11
+ * Android once Phase 3 lands).
12
+ * 2. Native side feeds the frames into `cv::Stitcher::SCANS`.
13
+ * 3. Frames are deleted; only the final panorama remains.
14
+ *
15
+ * The single bridge call keeps the JS thread out of the
16
+ * extract→stitch dance entirely; nothing has to round-trip frame
17
+ * paths back to JS just to hand them to a sibling native call.
18
+ */
19
+
20
+ import { NativeModules, Platform } from 'react-native';
21
+
22
+ import type { StitchFramesResult } from './stitchFrames';
23
+ import { StitchNotImplementedError } from './stitchFrames';
24
+
25
+
26
+ export interface StitchVideoOptions {
27
+ /**
28
+ * Absolute path to the recorded video file. Accepts paths with
29
+ * or without the `file://` prefix — the native module strips it.
30
+ */
31
+ videoPath: string;
32
+ /**
33
+ * Where the resulting panoramic JPEG should be written.
34
+ */
35
+ outputPath: string;
36
+ /**
37
+ * Number of frames to sample from the video. Default 10 — the
38
+ * empirical sweet spot for shelf scans on iPhone hardware: enough
39
+ * overlap that homography stays robust, few enough that stitching
40
+ * stays under 4 seconds. Increase only if the user pans further
41
+ * than ~1 m of shelf in a single hold.
42
+ */
43
+ maxFrames?: number;
44
+ /**
45
+ * JPEG quality [0..100]. Applied to BOTH the intermediate frames
46
+ * (extracted from video) AND the final panorama. Default 85.
47
+ */
48
+ quality?: number;
49
+ /**
50
+ * Projection used during the warp step. See native side for
51
+ * details. Default `'plane'` — straight verticals/horizontals,
52
+ * good for shelf scans + close-up subjects. Hourglass shape on
53
+ * partial arcs is handled by the rectangular-crop step.
54
+ * - `'plane'`: flat projection. Best for ≤30° pans.
55
+ * - `'cylindrical'`: handles rotational mid-arc pans.
56
+ * - `'spherical'`: full 90°+ panoramic captures.
57
+ */
58
+ warperType?: 'plane' | 'cylindrical' | 'spherical';
59
+ /**
60
+ * Blending strategy across stitched seams.
61
+ * - `'multiband'` (default): high-quality seams when alignment
62
+ * is good; can produce visible halos when adjacent frames
63
+ * have inconsistent exposure.
64
+ * - `'feather'`: simple alpha-weighted blend; faster + no
65
+ * halo artifacts; slightly more visible seam edge.
66
+ */
67
+ blenderType?: 'multiband' | 'feather';
68
+ /**
69
+ * Seam finding strategy.
70
+ * - `'graphcut'` (default): cv::detail::GraphCutSeamFinder
71
+ * finds optimal cuts before blending — pairs beautifully
72
+ * with `multiband`, but holds all warped frames in memory
73
+ * simultaneously (higher peak).
74
+ * - `'skip'`: streams warp+feed in a single pass, never
75
+ * holding more than one warped frame. Lower peak memory.
76
+ * Right choice on low-RAM devices or with `feather`.
77
+ */
78
+ seamFinderType?: 'graphcut' | 'skip';
79
+ /**
80
+ * Phase 5: pose-driven stitching. When present and non-empty,
81
+ * the native stitcher skips features → matching → BundleAdjuster
82
+ * and builds cv::detail::CameraParams directly from each pose's
83
+ * intrinsics + quaternion. Each entry has the shape returned
84
+ * by `NativeModules.RNSARSession.snapshotPoseLog()`:
85
+ *
86
+ * { tx, ty, tz, qx, qy, qz, qw,
87
+ * fx, fy, cx, cy,
88
+ * imageWidth, imageHeight,
89
+ * timestampMs, trackingState }
90
+ *
91
+ * Frames whose closest pose is beyond a 100 ms tolerance are
92
+ * dropped before stitching; if fewer than 2 remain the call
93
+ * rejects with `opencv-failed-1032` so the host can fall back
94
+ * to the feature-matched path (re-call `stitchVideo` without
95
+ * `poses`).
96
+ */
97
+ poses?: Array<{
98
+ tx: number; ty: number; tz: number;
99
+ qx: number; qy: number; qz: number; qw: number;
100
+ fx: number; fy: number; cx: number; cy: number;
101
+ imageWidth: number; imageHeight: number;
102
+ timestampMs: number;
103
+ trackingState: number;
104
+ }>;
105
+ }
106
+
107
+
108
+ /**
109
+ * Stitch a recorded video file into a single panoramic JPEG.
110
+ *
111
+ * Throws `StitchNotImplementedError` on platforms where the native
112
+ * stitcher hasn't shipped yet (Android until Phase 3). Throws an
113
+ * Error with a code-like message on stitcher failures the JS layer
114
+ * may want to recover from (see `StitcherError` cases on the native
115
+ * side: `insufficient-frames`, `read-failed`, `write-failed`,
116
+ * `opencv-failed-<code>`).
117
+ */
118
+ export async function stitchVideo(
119
+ options: StitchVideoOptions,
120
+ ): Promise<StitchFramesResult> {
121
+ const native: unknown =
122
+ (NativeModules as Record<string, unknown>)['BatchStitcher'];
123
+ if (
124
+ native
125
+ && typeof native === 'object'
126
+ && typeof (native as { stitchVideo?: unknown }).stitchVideo === 'function'
127
+ ) {
128
+ const fn = (native as {
129
+ stitchVideo: (o: StitchVideoOptions) => Promise<StitchFramesResult>;
130
+ }).stitchVideo;
131
+ // 60-second hard deadline. cv::Stitcher::stitch can occasionally
132
+ // grind for minutes inside bundle adjustment on hard inputs (low
133
+ // texture, extreme parallax). A timeout converts those into a
134
+ // clear UI error rather than letting the stitching banner sit
135
+ // forever. Real stitches finish in 2-8 seconds on iPhone with
136
+ // the tuned PANORAMA settings; 60 s is comfortably above that.
137
+ return Promise.race([
138
+ fn(options),
139
+ new Promise<StitchFramesResult>((_, reject) => {
140
+ setTimeout(
141
+ () => reject(new Error('stitch-timeout: stitching took longer than 30 s')),
142
+ 30_000,
143
+ );
144
+ }),
145
+ ]);
146
+ }
147
+
148
+ throw new StitchNotImplementedError(
149
+ `stitchVideo is not yet implemented on ${Platform.OS}. `
150
+ + 'The react-native-image-stitcher native stitcher module is expected '
151
+ + 'but not registered (or the build predates Phase 2.5 of #8).',
152
+ );
153
+ }
@@ -0,0 +1,273 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * useIncrementalJSDriver — vision-camera + gyro frame driver for
4
+ * the incremental panorama engine, used in non-AR captures on both
5
+ * iOS and Android.
6
+ *
7
+ * History: previously called `useIncrementalAndroidDriver` because
8
+ * it was Android-only. As of 2026-05-17 (Issue #2), the native
9
+ * `processFrameAtPath` entry point exists on both platforms and the
10
+ * hook drives non-AR on iOS too; renamed 2026-05-19 to reflect
11
+ * that.
12
+ *
13
+ * Why this exists
14
+ * In AR captures the engine consumes frames from the ARSession
15
+ * stream natively (60 Hz pose + image delivery, zero JS
16
+ * involvement once started). In NON-AR captures there is no AR
17
+ * session — vision-camera owns the camera — so the engine needs
18
+ * another frame source. This hook fills the gap:
19
+ *
20
+ * - vision-camera keeps the camera viewport
21
+ * - `takeSnapshot()` runs at ~250 ms intervals during press-hold
22
+ * - `react-native-sensors` gyroscope is integrated to estimate
23
+ * cumulative yaw/pitch (drives the FoV-overlap gate)
24
+ * - Each snapshot path + integrated pose is fed to
25
+ * `IncrementalStitcher.processFrameAtPath()`
26
+ *
27
+ * Trade-off vs the AR path
28
+ * Gyro integration drifts ~1–2° per minute. Acceptable for the
29
+ * typical 5–15 s shelf pan; not great for ambitious 360° captures.
30
+ * Snapshot rate is ~4 Hz (vs 60 Hz in AR mode). Pose drives
31
+ * frame-selection only — the actual image alignment is feature-
32
+ * matched + RANSAC-fit, so quality of the panorama itself isn't
33
+ * bounded by gyro accuracy.
34
+ *
35
+ * Lifecycle
36
+ * `start({ cameraRef })` enables the loop; `stop()` tears down.
37
+ * Both should be called by the host's hold-start / hold-complete
38
+ * handlers. Safe to call on either platform; the hook only
39
+ * activates inside the start/stop block.
40
+ */
41
+
42
+ import { useCallback, useRef } from 'react';
43
+ import { NativeModules } from 'react-native';
44
+ import {
45
+ gyroscope,
46
+ setUpdateIntervalForType,
47
+ SensorTypes,
48
+ } from 'react-native-sensors';
49
+ import type { Subscription } from 'rxjs';
50
+ import type { Camera } from 'react-native-vision-camera';
51
+
52
+
53
+ export interface UseIncrementalJSDriverOptions {
54
+ /**
55
+ * Snapshot interval in ms. Default 250 (≈ 4 Hz). Lower = more
56
+ * candidate frames + more disk I/O. Don't go below 200 — vision-
57
+ * camera's snapshot pipeline can't keep up reliably below that.
58
+ */
59
+ snapshotIntervalMs?: number;
60
+ /**
61
+ * Gyro sample rate in ms (~30 Hz default matches the existing
62
+ * `PanoramaGuidance` cadence). Used for pose integration only —
63
+ * not the snapshot rate.
64
+ */
65
+ gyroIntervalMs?: number;
66
+ /**
67
+ * Approximate horizontal FoV of the device camera. Drives the
68
+ * overlap-percent calculation in the native engine. Default 65°
69
+ * is a reasonable mid-tier smartphone average.
70
+ */
71
+ fovHorizDegrees?: number;
72
+ /**
73
+ * Approximate vertical FoV of the device camera. Default 50° for
74
+ * typical 4:3 phone cameras. When ARCore-driven path is in use
75
+ * the engine receives both FoVs straight from intrinsics; the
76
+ * gyro driver is a fallback so the defaults are good enough.
77
+ */
78
+ fovVertDegrees?: number;
79
+ }
80
+
81
+
82
+ export interface IncrementalJSDriverHandle {
83
+ start: (cameraRef: React.RefObject<Camera | null>) => void;
84
+ stop: () => void;
85
+ isRunning: boolean;
86
+ }
87
+
88
+
89
+ interface NativeProcessFrame {
90
+ processFrameAtPath(options: {
91
+ path: string;
92
+ yaw: number;
93
+ pitch: number;
94
+ fovHorizDegrees: number;
95
+ fovVertDegrees: number;
96
+ trackingPoor: boolean;
97
+ /** Quaternion (x, y, z, w) — pose-driven path. */
98
+ qx: number; qy: number; qz: number; qw: number;
99
+ /** Sensor-resolution intrinsics. */
100
+ fx: number; fy: number; cx: number; cy: number;
101
+ imageWidth: number;
102
+ imageHeight: number;
103
+ }): Promise<unknown>;
104
+ }
105
+
106
+
107
+ function getNativeIncremental(): NativeProcessFrame | null {
108
+ const m = (NativeModules as Record<string, unknown>)['IncrementalStitcher'];
109
+ if (!m || typeof m !== 'object') return null;
110
+ return m as NativeProcessFrame;
111
+ }
112
+
113
+
114
+ export function useIncrementalJSDriver(
115
+ options: UseIncrementalJSDriverOptions = {},
116
+ ): IncrementalJSDriverHandle {
117
+ const {
118
+ snapshotIntervalMs = 250,
119
+ gyroIntervalMs = 33,
120
+ fovHorizDegrees = 65,
121
+ fovVertDegrees = 50,
122
+ } = options;
123
+
124
+ const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
125
+ const gyroSubRef = useRef<Subscription | null>(null);
126
+ const cameraRef = useRef<React.RefObject<Camera | null> | null>(null);
127
+ // Integrated pose accumulators, in radians. Reset on each
128
+ // start() call. Y-axis = horizontal pan (yaw), X-axis = vertical
129
+ // pan (pitch). Sign convention matches ARKit: counter-clockwise
130
+ // from above is positive yaw.
131
+ const yawRef = useRef(0);
132
+ const pitchRef = useRef(0);
133
+ const lastGyroAtRef = useRef<number | null>(null);
134
+ // Single in-flight guard so we don't pile up overlapping snapshot
135
+ // promises on slow devices — if last snapshot hasn't finished
136
+ // when the next interval fires, skip.
137
+ const snapshotInFlightRef = useRef(false);
138
+ // Module-level "is the driver active right now" — exposed to the
139
+ // host because the hook itself doesn't trigger re-renders.
140
+ const isRunningRef = useRef(false);
141
+
142
+ const stop = useCallback(() => {
143
+ if (intervalRef.current) {
144
+ clearInterval(intervalRef.current);
145
+ intervalRef.current = null;
146
+ }
147
+ if (gyroSubRef.current) {
148
+ gyroSubRef.current.unsubscribe();
149
+ gyroSubRef.current = null;
150
+ }
151
+ cameraRef.current = null;
152
+ isRunningRef.current = false;
153
+ }, []);
154
+
155
+ const start = useCallback(
156
+ (cameraRefArg: React.RefObject<Camera | null>) => {
157
+ // 2026-05-17 (Issue #2) — removed the Android-only platform
158
+ // guard. iOS now also exposes `processFrameAtPath` (see the
159
+ // Swift bridge), so the same driver feeds both platforms in
160
+ // non-AR mode.
161
+ if (isRunningRef.current) return;
162
+ const native = getNativeIncremental();
163
+ if (!native) return;
164
+
165
+ cameraRef.current = cameraRefArg;
166
+ yawRef.current = 0;
167
+ pitchRef.current = 0;
168
+ lastGyroAtRef.current = null;
169
+ snapshotInFlightRef.current = false;
170
+ isRunningRef.current = true;
171
+
172
+ // Gyro integration. Each sample carries angular velocity in
173
+ // rad/s; multiply by elapsed time to accumulate angular
174
+ // displacement. Note: the gyro axes are device-local; we use
175
+ // y for yaw and x for pitch on a device held in portrait.
176
+ // Landscape would swap, but the FoV-overlap gate is dominant-
177
+ // axis based on the .mm side, so the convention matters less
178
+ // than consistency.
179
+ setUpdateIntervalForType(SensorTypes.gyroscope, gyroIntervalMs);
180
+ gyroSubRef.current = gyroscope.subscribe({
181
+ next: ({ x, y }) => {
182
+ const now = Date.now();
183
+ if (lastGyroAtRef.current === null) {
184
+ lastGyroAtRef.current = now;
185
+ return;
186
+ }
187
+ const dt = (now - lastGyroAtRef.current) / 1000.0;
188
+ lastGyroAtRef.current = now;
189
+ yawRef.current += y * dt;
190
+ pitchRef.current += x * dt;
191
+ },
192
+ error: (err) => {
193
+ // eslint-disable-next-line no-console
194
+ console.warn('[useIncrementalJSDriver] gyro error', err);
195
+ },
196
+ });
197
+
198
+ // Snapshot loop.
199
+ const tick = async () => {
200
+ if (snapshotInFlightRef.current) return;
201
+ const cam = cameraRef.current?.current;
202
+ if (!cam) return;
203
+ snapshotInFlightRef.current = true;
204
+ try {
205
+ const snap = await cam.takeSnapshot({ quality: 70 });
206
+ if (!snap?.path) return;
207
+ // Synthesise a quaternion from integrated yaw + pitch.
208
+ // Yaw rotates about world Y (gravity), pitch about world X
209
+ // (perpendicular to gravity in the device's frame).
210
+ // Combined as q = q_yaw · q_pitch.
211
+ const halfYaw = yawRef.current / 2;
212
+ const halfPitch = pitchRef.current / 2;
213
+ const cy_ = Math.cos(halfYaw);
214
+ const sy_ = Math.sin(halfYaw);
215
+ const cp = Math.cos(halfPitch);
216
+ const sp = Math.sin(halfPitch);
217
+ // q_yaw = (0, sy, 0, cy)
218
+ // q_pitch = (sp, 0, 0, cp)
219
+ // q = q_yaw * q_pitch:
220
+ const qx = cy_ * sp;
221
+ const qy = sy_ * cp;
222
+ const qz = -sy_ * sp;
223
+ const qw = cy_ * cp;
224
+
225
+ // Vision-camera v4 doesn't expose camera intrinsics on
226
+ // Android, so we estimate fx/fy from the snapshot's pixel
227
+ // dimensions + assumed FoV. cx/cy at image centre. This
228
+ // is approximate; the proper Android live path is the
229
+ // ARCameraView, where ARCore gives us the real intrinsics.
230
+ const w = snap.width ?? 1920;
231
+ const h = snap.height ?? 1440;
232
+ const fx = w / (2.0 * Math.tan(((fovHorizDegrees * Math.PI) / 180) / 2));
233
+ const fy = h / (2.0 * Math.tan(((fovVertDegrees * Math.PI) / 180) / 2));
234
+ const cx = w / 2;
235
+ const cy = h / 2;
236
+
237
+ await native.processFrameAtPath({
238
+ path: snap.path,
239
+ yaw: yawRef.current,
240
+ pitch: pitchRef.current,
241
+ fovHorizDegrees,
242
+ fovVertDegrees,
243
+ trackingPoor: false,
244
+ qx, qy, qz, qw,
245
+ fx, fy, cx, cy,
246
+ imageWidth: w, imageHeight: h,
247
+ });
248
+ } catch (err) {
249
+ // Swallow per-frame errors so the loop keeps running.
250
+ // eslint-disable-next-line no-console
251
+ console.warn(
252
+ '[useIncrementalJSDriver] processFrame failed', err,
253
+ );
254
+ } finally {
255
+ snapshotInFlightRef.current = false;
256
+ }
257
+ };
258
+ // Kick off an immediate first frame so the engine doesn't sit
259
+ // idle for the first interval period.
260
+ tick();
261
+ intervalRef.current = setInterval(tick, snapshotIntervalMs);
262
+ },
263
+ [snapshotIntervalMs, gyroIntervalMs, fovHorizDegrees, fovVertDegrees],
264
+ );
265
+
266
+ return {
267
+ start,
268
+ stop,
269
+ get isRunning(): boolean {
270
+ return isRunningRef.current;
271
+ },
272
+ };
273
+ }