react-native-image-stitcher 0.1.2 → 0.2.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.
@@ -1,235 +1,139 @@
1
1
  "use strict";
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
  //
4
- // useIMUTranslationGate.ts — JS-side IMU translation tracker for the
5
- // non-AR translation-warning banner + (optional) gate force-accept.
6
- //
7
- // 2026-05-17 (Issue #4-A v3): rewritten on top of `expo-sensors`
8
- // `DeviceMotion` (which returns gravity-subtracted linear
9
- // acceleration via Apple's CoreMotion fusion on iOS and Android's
10
- // `TYPE_LINEAR_ACCELERATION` sensor on Android — both significantly
11
- // less noisy than raw accel + JS-side IIR gravity subtraction).
12
- // Tracks a SINGLE device-frame axis (device-X — the phone's lateral /
13
- // short side) rather than the 3D translation magnitude.
14
- //
15
- // Why exists
16
- // ──────────
17
- //
18
- // In non-AR mode the SDK has no ARSession pose stream, so the shared
19
- // C++ `KeyframeGate`'s translation-budget feature stays at zero and
20
- // never trips. This hook fills the gap on the JS side and emits a
21
- // budget-crossed callback the host can wire to either:
22
- //
23
- // (a) `markNextFrameAsLastKeyframe()`tell the gate "force-accept
24
- // the next frame regardless of overlap", so the trailing-edge
25
- // frame still lands when the operator translates instead of
26
- // rotates.
27
- //
28
- // (b) A user-facing warning banner ("Rotate the camera instead of
29
- // moving it sideways" — see AuditCaptureScreen).
30
- //
31
- // AuditCaptureScreen wires it to both.
4
+ // useIMUTranslationGate — JS-side IMU translation tracker that fires
5
+ // a callback when integrated lateral displacement on the device-X
6
+ // axis exceeds a budget. Drives `<Camera>`'s non-AR keyframe-
7
+ // acceptance path: every time the gate fires, the host calls the
8
+ // C++ engine's `markNextFrameAsLastKeyframe()` so the trailing frame
9
+ // lands as a keyframe regardless of what the flow-novelty algorithm
10
+ // alone would decide.
11
+ //
12
+ // V0.2 history note
13
+ // ─────────────────
14
+ // 0.1.x used `expo-sensors`' `DeviceMotion.acceleration`, which
15
+ // returned gravity-subtracted linear acceleration via CoreMotion's
16
+ // native fusion (iOS) / Android's `TYPE_LINEAR_ACCELERATION` sensor
17
+ // (Android) — both significantly less noisy than raw accel + JS-side
18
+ // gravity subtraction. v0.2 drops the Expo modules dependency
19
+ // (see CHANGELOG / docs/host-app-integration.md), so the gate is now
20
+ // implemented on `react-native-sensors`' raw `accelerometer` with a
21
+ // JS-side IIR low-pass to estimate the gravity vector. The IIR
22
+ // version is noisier — expect a few extra cm of apparent drift on a
23
+ // stationary phone over several seconds but the budget threshold
24
+ // (~8 cm at default `flowMaxTranslationCm = 8`) and the anchor
25
+ // resets (every accepted keyframe + recording start) keep the
26
+ // per-interval drift window short enough that the budget still
27
+ // meaningfully discriminates real translation from noise.
32
28
  //
33
29
  // Why device-X (the shorter side)
34
30
  // ───────────────────────────────
35
- //
36
31
  // We track motion ALONG the pan axis (the direction the operator is
37
32
  // supposed to be rotating-through but might be translating-through
38
- // instead) because translation orthogonal to the pan axis is
39
- // acceptable — vertical translation while panning horizontally in
40
- // portrait, for example, doesn't cause horizontal parallax.
41
- //
42
- // The pan axis maps to device-X in BOTH supported orientations
43
- // (per memory/ar-stitching-two-modes.md):
44
- //
45
- // Portrait + horizontal pan: device-X = user-left/right = pan axis.
33
+ // instead). In BOTH supported pan modes the pan axis maps to
34
+ // device-X:
35
+ // Portrait + horizontal pan: device-X = user-left/right.
46
36
  // Landscape + vertical pan: device-X has rotated 90° into the
47
- // user's up/down direction = pan axis.
48
- //
49
- // The lateral axis of the phone (its short side) always aligns with
50
- // the pan direction in either supported mode, so a single-axis
51
- // tracker works without needing to know which orientation we're in.
37
+ // user's up/down direction.
38
+ // So a single-axis tracker works without knowing the orientation.
52
39
  //
53
40
  // Drift mitigation
54
41
  // ────────────────
55
- //
56
- // `DeviceMotion.acceleration` (gravity removed in native code via
57
- // IMU fusion) has a noise floor roughly 30-50 % lower than what the
58
- // previous raw-accel + JS IIR pipeline produced. Single-axis math
59
- // further reduces apparent drift by ≈√3 vs the prior 3D magnitude.
60
- // Together they should keep the typical "stationary phone" reading
61
- // below ~5-10 cm even after several seconds.
62
- //
63
- // Anchor resets happen at (a) recording start (via the host calling
64
- // `resetAnchor()` from handleHoldStart) and (b) every accepted
65
- // keyframethese bound the per-interval drift window to typically
66
- // 0.3-2 s.
67
- //
68
- // What we no longer do
69
- // ────────────────────
70
- //
71
- // - JS-side 1-pole IIR for gravity subtraction (native API gives
72
- // gravity-subtracted accel directly).
73
- // - 3D vector magnitude (now single device-X axis).
74
- // - Velocity damping (kept as a safety net at 5%/sample so a
75
- // persistent noise-floor offset doesn't slowly drift the axis —
76
- // low cost, high robustness).
42
+ // 1. IIR low-pass on the raw X accel estimates the gravity offset.
43
+ // Subtracting that gives linear-acceleration-on-X. Alpha = 0.9
44
+ // at the default 50 Hz sample rate ~200 ms gravity tracking
45
+ // time constant. Slow enough that hand motion (>1 Hz) gets
46
+ // through; fast enough to converge after device rotations within
47
+ // ~1 second.
48
+ // 2. Per-sample velocity damping at 5 % so a constant noise-floor
49
+ // offset decays to ~1 % of its initial value in 2 s. This caps
50
+ // apparent drift for a stationary phone.
51
+ // 3. Anchor reset on recording start AND every accepted keyframe
52
+ // (callers do this) bounds the integration window to typically
53
+ // 0.3-2 s, well inside the regime where IIR-estimated linear
54
+ // accel is usable.
55
+ //
56
+ // Platform unit handling
57
+ // ──────────────────────
58
+ // `react-native-sensors`' accelerometer reports:
59
+ // iOS: values in G's (multiples of 9.81 m/s²), via CoreMotion.
60
+ // Android: values in m/s², via Sensor.TYPE_ACCELEROMETER.
61
+ // We scale iOS by `G_TO_MPS2` so the integration math stays in
62
+ // standard m/s², m/s, m units. Sign convention doesn't matter for
63
+ // the gate because the gravity offset is estimated and subtracted
64
+ // per-axis; what's left is the platform-agnostic linear acceleration.
77
65
  Object.defineProperty(exports, "__esModule", { value: true });
78
66
  exports.useIMUTranslationGate = useIMUTranslationGate;
79
67
  const react_1 = require("react");
80
- const expo_sensors_1 = require("expo-sensors");
81
- /**
82
- * IMU-based translation tracker — single-axis (device-X / pan axis),
83
- * fused IMU via `expo-sensors` `DeviceMotion`. See file header for
84
- * algorithm + rationale. No platform-specific code; the underlying
85
- * native fusion is platform-aware (CoreMotion on iOS, fused
86
- * `TYPE_LINEAR_ACCELERATION` on Android).
87
- */
88
- function useIMUTranslationGate(options) {
89
- const { enabled, budgetMeters = 0.40, sampleIntervalMs = 20, onBudgetExceeded, debug = false, } = options;
90
- // Integrator state, kept in refs so the listener can write without
91
- // re-creating its closure on every render.
92
- // velX : velocity along device-X (m/s)
93
- // posX : position along device-X (m)
94
- // lastMs: epoch ms of the previous sample (for dt)
95
- // budgetCrossed: debounce flag — clears on resetAnchor
96
- // sampleCount: rolling counter for debug log throttle
97
- // anchorMs: timestamp of the most recent resetAnchor (or first
98
- // sample) gives "time since anchor" in debug output
99
- const velX = (0, react_1.useRef)(0);
100
- const posX = (0, react_1.useRef)(0);
101
- const lastMs = (0, react_1.useRef)(0);
102
- const budgetCrossed = (0, react_1.useRef)(false);
103
- const sampleCount = (0, react_1.useRef)(0);
104
- const anchorMs = (0, react_1.useRef)(0);
105
- // Keep the callback in a ref so we don't tear down + re-subscribe
106
- // on every prop change. React idiom for stable callback identity.
107
- const onBudgetExceededRef = (0, react_1.useRef)(onBudgetExceeded);
108
- (0, react_1.useEffect)(() => { onBudgetExceededRef.current = onBudgetExceeded; }, [onBudgetExceeded]);
68
+ const react_native_1 = require("react-native");
69
+ const react_native_sensors_1 = require("react-native-sensors");
70
+ const DEFAULT_BUDGET_METERS = 0.40;
71
+ const DEFAULT_SAMPLE_INTERVAL_MS = 20;
72
+ /// Per-sample multiplicative damping on the velocity integrator.
73
+ /// 5 % at 50 Hz constant offset decays to ~1 % in 2 s. Bounds
74
+ /// the apparent-drift window for a stationary phone.
75
+ const VELOCITY_DAMPING_PER_SAMPLE = 0.05;
76
+ /// IIR low-pass coefficient for the gravity estimate. At 50 Hz
77
+ /// this gives ~200 ms time constant. Higher = slower gravity
78
+ /// tracking (more lag during device rotation, less hand-motion
79
+ /// bleed into the gravity estimate); lower = faster.
80
+ const GRAVITY_IIR_ALPHA = 0.9;
81
+ /// 1 G in m/s². Standard gravity per CGPM 1901 (good to all the
82
+ /// digits anyone cares about for this application).
83
+ const G_TO_MPS2 = 9.81;
84
+ function useIMUTranslationGate({ enabled, budgetMeters = DEFAULT_BUDGET_METERS, sampleIntervalMs = DEFAULT_SAMPLE_INTERVAL_MS, onBudgetExceeded, }) {
85
+ // All running-integrator state lives in a single ref so the
86
+ // subscription callback can update it without forcing a re-render
87
+ // every frame (50 Hz worth of re-renders would tank performance).
88
+ const stateRef = (0, react_1.useRef)({
89
+ posX: 0,
90
+ velX: 0,
91
+ /// NaN sentinel for "uninitialised"; first sample seeds it.
92
+ gravityX: NaN,
93
+ fired: false,
94
+ });
95
+ // Latest onBudgetExceeded callback in a ref so callers can pass
96
+ // an inline closure that captures fresh state without us re-
97
+ // subscribing the sensor (which would reset the integrators).
98
+ const onExceededRef = (0, react_1.useRef)(onBudgetExceeded);
99
+ onExceededRef.current = onBudgetExceeded;
100
+ const resetAnchor = (0, react_1.useCallback)(() => {
101
+ const s = stateRef.current;
102
+ s.posX = 0;
103
+ s.velX = 0;
104
+ s.fired = false;
105
+ // s.gravityX is intentionally preserved — see header.
106
+ }, []);
109
107
  (0, react_1.useEffect)(() => {
110
- if (debug) {
111
- // eslint-disable-next-line no-console
112
- console.log(`[IMUTransGate] effect re-run: enabled=${enabled} `
113
- + `budget=${budgetMeters.toFixed(2)}m sampleIntervalMs=${sampleIntervalMs}`);
114
- }
115
108
  if (!enabled)
116
109
  return;
117
- // Lock in the DeviceMotion update rate. Other expo-sensors
118
- // consumers in the SDK can override later; the LAST setter wins
119
- // per Expo's docs, which is fine because our budget logic
120
- // tolerates a wide range of cadences.
121
- expo_sensors_1.DeviceMotion.setUpdateInterval(sampleIntervalMs);
122
- // Reset state on (re-)engage so the first measurement after
123
- // enabled-toggles-true doesn't carry stale velocity from a
124
- // previous capture session.
125
- velX.current = 0;
126
- posX.current = 0;
127
- lastMs.current = 0;
128
- budgetCrossed.current = false;
129
- sampleCount.current = 0;
130
- anchorMs.current = Date.now();
131
- // 2026-05-18 (Issue #3 diagnostics) — track whether we've ever
132
- // received a non-null `acceleration` for this subscription. If
133
- // the user reports "no logs" we can correlate with these
134
- // start-of-subscription and first-real-data markers.
135
- let everGotData = false;
136
- let nullSampleCount = 0;
137
- if (debug) {
138
- // eslint-disable-next-line no-console
139
- console.log('[IMUTransGate] subscribing to DeviceMotion');
140
- }
141
- const sub = expo_sensors_1.DeviceMotion.addListener((m) => {
142
- const a = m.acceleration; // gravity-subtracted (m/s²)
143
- if (!a) {
144
- nullSampleCount += 1;
145
- if (debug && nullSampleCount === 1) {
146
- // eslint-disable-next-line no-console
147
- console.log('[IMUTransGate] first sample: acceleration=null '
148
- + '(CoreMotion warming up; will retry on next sample)');
149
- }
150
- if (debug && nullSampleCount > 0 && nullSampleCount % 100 === 0) {
151
- // eslint-disable-next-line no-console
152
- console.log(`[IMUTransGate] STILL receiving null acceleration after `
153
- + `${nullSampleCount} samples — sensor source may be broken`);
154
- }
155
- return; // can be null briefly on cold start
156
- }
157
- if (debug && !everGotData) {
158
- everGotData = true;
159
- // eslint-disable-next-line no-console
160
- console.log(`[IMUTransGate] first real sample: ax=${a.x.toFixed(3)} `
161
- + `ay=${a.y.toFixed(3)} az=${a.z.toFixed(3)} m/s² `
162
- + `(after ${nullSampleCount} null sample(s))`);
163
- }
164
- const now = Date.now();
165
- if (lastMs.current === 0) {
166
- lastMs.current = now;
110
+ (0, react_native_sensors_1.setUpdateIntervalForType)(react_native_sensors_1.SensorTypes.accelerometer, sampleIntervalMs);
111
+ const scale = react_native_1.Platform.OS === 'ios' ? G_TO_MPS2 : 1;
112
+ const dt = sampleIntervalMs / 1000.0;
113
+ const sub = react_native_sensors_1.accelerometer.subscribe(({ x }) => {
114
+ const ax = x * scale; // device-X acceleration in m/s²
115
+ const s = stateRef.current;
116
+ // First sample: seed gravity from this reading. Assumes the
117
+ // phone is roughly stationary at recording start — true in
118
+ // practice because the operator just tap-and-held the shutter.
119
+ if (Number.isNaN(s.gravityX)) {
120
+ s.gravityX = ax;
167
121
  return;
168
122
  }
169
- const dt = Math.max(0, Math.min(0.1, (now - lastMs.current) / 1000.0));
170
- lastMs.current = now;
171
- if (dt === 0)
172
- return;
173
- // Single-axis integration along device-X (lateral / pan axis).
174
- // See file header for why device-X is the right axis in both
175
- // portrait and landscape captures.
176
- velX.current += a.x * dt;
177
- velX.current *= 0.95; // 5%/sample damping — see file header
178
- posX.current += velX.current * dt;
179
- const mag = Math.abs(posX.current);
180
- // 2026-05-18 (Issue #4 investigation) — debug-gated diagnostic
181
- // log. Throttled to every 20th sample (~400 ms at 50 Hz) so
182
- // the log isn't a firehose. When this runs and we still see
183
- // posX hovering at < 5 cm during a real translation, the
184
- // sensor source isn't capturing what we think it is.
185
- sampleCount.current += 1;
186
- if (debug && sampleCount.current % 20 === 0) {
187
- const secs = (now - anchorMs.current) / 1000.0;
188
- // eslint-disable-next-line no-console
189
- console.log(`[IMUTransGate] t+${secs.toFixed(2)}s `
190
- + `ax=${a.x.toFixed(3)}m/s² `
191
- + `velX=${velX.current.toFixed(4)}m/s `
192
- + `posX=${posX.current.toFixed(4)}m `
193
- + `(|mag|=${mag.toFixed(4)}m, budget=${budgetMeters.toFixed(2)}m, crossed=${budgetCrossed.current})`);
194
- }
195
- // Budget crossing — fire exactly once per crossing (the
196
- // `budgetCrossed` flag clears on `resetAnchor`).
197
- if (!budgetCrossed.current && mag >= budgetMeters) {
198
- budgetCrossed.current = true;
199
- if (debug) {
200
- // eslint-disable-next-line no-console
201
- console.log(`[IMUTransGate] BUDGET CROSSED at posX=${posX.current.toFixed(4)}m `
202
- + `(budget=${budgetMeters.toFixed(2)}m)`);
203
- }
204
- onBudgetExceededRef.current();
123
+ // IIR low-pass to track the gravity component on device-X.
124
+ s.gravityX = GRAVITY_IIR_ALPHA * s.gravityX + (1 - GRAVITY_IIR_ALPHA) * ax;
125
+ // Linear acceleration on X = raw - gravity estimate.
126
+ const linX = ax - s.gravityX;
127
+ // Single integration with per-sample velocity damping.
128
+ s.velX = (s.velX + linX * dt) * (1 - VELOCITY_DAMPING_PER_SAMPLE);
129
+ s.posX += s.velX * dt;
130
+ if (!s.fired && Math.abs(s.posX) > budgetMeters) {
131
+ s.fired = true;
132
+ onExceededRef.current();
205
133
  }
206
134
  });
207
- return () => {
208
- if (debug) {
209
- // eslint-disable-next-line no-console
210
- console.log(`[IMUTransGate] unsubscribing (everGotData=${everGotData}, `
211
- + `nullSamples=${nullSampleCount}, realSamples=${sampleCount.current})`);
212
- }
213
- sub.remove();
214
- };
215
- }, [enabled, budgetMeters, sampleIntervalMs, debug]);
216
- // 2026-05-18 (Issue B meta-bug fix): wrap the returned functions in
217
- // useCallback so the hook's return value is REFERENTIALLY STABLE
218
- // across renders. Consumer code (AuditCaptureScreen) puts `imuGate`
219
- // in a useEffect dep array; without stability that effect re-runs
220
- // every render, wiping out the prevAcceptedCount delta-tracker
221
- // (which is what caused the resetAnchor-too-often bug we just
222
- // diagnosed). These functions only touch refs, so empty-deps
223
- // useCallback is safe — no stale-closure risk.
224
- const resetAnchor = (0, react_1.useCallback)(() => {
225
- velX.current = 0;
226
- posX.current = 0;
227
- lastMs.current = 0;
228
- budgetCrossed.current = false;
229
- sampleCount.current = 0;
230
- anchorMs.current = Date.now();
231
- }, []);
232
- const getCurrentTranslationM = (0, react_1.useCallback)(() => Math.abs(posX.current), []);
233
- return { resetAnchor, getCurrentTranslationM };
135
+ return () => sub.unsubscribe();
136
+ }, [enabled, budgetMeters, sampleIntervalMs]);
137
+ return { resetAnchor };
234
138
  }
235
139
  //# sourceMappingURL=useIMUTranslationGate.js.map
@@ -5,7 +5,7 @@
5
5
  * - iOS: Swift native module that vendors upstream OpenCV's iOS
6
6
  * framework and calls `cv::Stitcher::SCANS` mode (designed for
7
7
  * translational shelf captures). Lives in
8
- * `retailens-capture-sdk/ios/Sources/RNImageStitcher/`.
8
+ * `react-native-image-stitcher/ios/Sources/RNImageStitcher/`.
9
9
  * - Android: deferred to Phase 3 — same OpenCV surface, different
10
10
  * build (NDK + Gradle). Until that lands, Android calls hit the
11
11
  * `StitchNotImplementedError` path below.
@@ -7,7 +7,7 @@
7
7
  * - iOS: Swift native module that vendors upstream OpenCV's iOS
8
8
  * framework and calls `cv::Stitcher::SCANS` mode (designed for
9
9
  * translational shelf captures). Lives in
10
- * `retailens-capture-sdk/ios/Sources/RNImageStitcher/`.
10
+ * `react-native-image-stitcher/ios/Sources/RNImageStitcher/`.
11
11
  * - Android: deferred to Phase 3 — same OpenCV surface, different
12
12
  * build (NDK + Gradle). Until that lands, Android calls hit the
13
13
  * `StitchNotImplementedError` path below.
package/ios/Package.swift CHANGED
@@ -16,7 +16,7 @@
16
16
  //
17
17
  // Run from this directory:
18
18
  //
19
- // cd retailens-capture-sdk/ios
19
+ // cd react-native-image-stitcher/ios
20
20
  // swift test
21
21
 
22
22
  import PackageDescription
@@ -289,7 +289,7 @@ public final class IncrementalStitcher: NSObject {
289
289
  /// fix is non-trivial; deferred until pose-driven stitch work
290
290
  /// lands (which will rework the queue topology anyway).
291
291
  private let workQueue = DispatchQueue(
292
- label: "com.retailens.incremental.stitcher",
292
+ label: "io.imagestitcher.incremental.stitcher",
293
293
  qos: .userInitiated
294
294
  )
295
295
 
@@ -306,7 +306,7 @@ public final class IncrementalStitcher: NSObject {
306
306
  /// is out of scope for this MVP — see prompt's "deliberately out
307
307
  /// of scope" list).
308
308
  private let refineQueue = DispatchQueue(
309
- label: "com.retailens.incremental.refine",
309
+ label: "io.imagestitcher.incremental.refine",
310
310
  qos: .utility
311
311
  )
312
312
 
@@ -1136,7 +1136,7 @@ public final class IncrementalStitcher: NSObject {
1136
1136
  // Why this matters (RCA from Sentry crashes 2026-05-09
1137
1137
  // 21:59-22:03, all 3 .ips traces):
1138
1138
  // EXC_BAD_ACCESS at objc_retain+16, frame 1 = closure #1
1139
- // in finalize+2648, queue = com.retailens.incremental.
1139
+ // in finalize+2648, queue = io.imagestitcher.incremental.
1140
1140
  // stitcher. +2648 lands inside the os_log call that
1141
1141
  // bridges self.batchWarperType → NSString via
1142
1142
  // swift_bridgeObjectRetain → objc_retain. The retain
@@ -1269,7 +1269,7 @@ public final class IncrementalStitcher: NSObject {
1269
1269
  // under stateLock, closing the visible torn-pointer race.
1270
1270
  // Three Sentry traces post-fix4 still showed the same crash
1271
1271
  // signature (frame 1 = closure #1 in finalize+N, queue =
1272
- // com.retailens.incremental.stitcher), which per the
1272
+ // io.imagestitcher.incremental.stitcher), which per the
1273
1273
  // systematic-debugging skill (3+ fixes failed on the same
1274
1274
  // symptom = wrong architecture) means the workQueue.async
1275
1275
  // pattern itself is the problem, not any specific captured
@@ -4,7 +4,7 @@
4
4
  //
5
5
  // This file used to BE the algorithm (~545 lines of Swift simd math).
6
6
  // As of P3-B of the Android-iOS parity work, the algorithm lives in
7
- // retailens-capture-sdk/cpp/keyframe_gate.{hpp,cpp} and is shared with
7
+ // react-native-image-stitcher/cpp/keyframe_gate.{hpp,cpp} and is shared with
8
8
  // the Android side via JNI. This Swift class is now a thin facade
9
9
  // that:
10
10
  //
@@ -1,7 +1,7 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  //
3
3
  // KeyframeGateBridge.h — Obj-C++ wrapper exposing the shared C++
4
- // KeyframeGate (in retailens-capture-sdk/cpp/) to Swift.
4
+ // KeyframeGate (in react-native-image-stitcher/cpp/) to Swift.
5
5
  //
6
6
  // Why this exists:
7
7
  // The pose-driven keyframe-selection algorithm is the single most
@@ -18,7 +18,7 @@
18
18
  // Single source of truth for the reason-code → string mapping. These
19
19
  // strings MUST stay 1:1 with the labels emitted by the original
20
20
  // KeyframeGate.swift (and read by the JS telemetry layer in
21
- // retailens-capture-sdk/src/stitching/incremental.ts). Drift will
21
+ // react-native-image-stitcher/src/stitching/incremental.ts). Drift will
22
22
  // silently break the JS UI's pill text.
23
23
  static NSString *kReasonStringFor(retailens::KeyframeGateDecisionReason r) {
24
24
  using R = retailens::KeyframeGateDecisionReason;
@@ -150,7 +150,7 @@ public final class RNSARSession: NSObject, ARSessionDelegate {
150
150
  /// recordings. Phase 5 stitching will query by timestamp.
151
151
  private var poseLog: [(TimeInterval, RNSARFramePose)] = []
152
152
  private let poseLogQueue = DispatchQueue(
153
- label: "com.retailens.arsession.poselog",
153
+ label: "io.imagestitcher.arsession.poselog",
154
154
  attributes: .concurrent
155
155
  )
156
156
  private static let MAX_POSE_LOG = 600 // ~10 s @ 60Hz
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-image-stitcher",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Pose-aware panorama capture + stitching for React Native. One <Camera> component, both tap-to-photo and hold-to-pan modes, both AR-backed and IMU-fallback capture paths.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -53,7 +53,6 @@
53
53
  "homepage": "https://github.com/bhargavkanda/react-native-image-stitcher#readme",
54
54
  "devDependencies": {
55
55
  "@types/react": "^19.0.0",
56
- "expo-sensors": "^14.0.0",
57
56
  "react": "^19.0.0",
58
57
  "react-native": "^0.84.0",
59
58
  "react-native-safe-area-context": "^4.0.0",
@@ -67,7 +66,6 @@
67
66
  "react-native": ">=0.72.0",
68
67
  "react-native-vision-camera": ">=4.7.0",
69
68
  "react-native-sensors": ">=7.0.0",
70
- "expo-sensors": ">=14.0.0",
71
69
  "react-native-safe-area-context": ">=4.0.0"
72
70
  }
73
71
  }
@@ -661,7 +661,11 @@ export function Camera(props: CameraProps): React.JSX.Element {
661
661
  return () => { cancelled = true; };
662
662
  }, [isAR, lens]);
663
663
 
664
- // IMU translation gate — only in non-AR mode.
664
+ // IMU translation gate — only engaged in non-AR mode. Fires when
665
+ // the operator's lateral hand motion exceeds the budget, telling
666
+ // the C++ engine to force-accept the next frame. This is what
667
+ // keeps non-AR captures producing keyframes at all (the flow-
668
+ // novelty algorithm alone is too strict in practice).
665
669
  const imuGate = useIMUTranslationGate({
666
670
  enabled:
667
671
  isNonAR