react-native-image-stitcher 0.6.0 → 0.7.1

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,6 +16,149 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
16
16
 
17
17
  ## [Unreleased]
18
18
 
19
+ ## [0.7.1] — 2026-05-26
20
+
21
+ ### Fixed — CI binary-packaging bloat
22
+
23
+ The v0.7.0 release (and likely v0.5.1 before it — both built by
24
+ CI) shipped uncompressed binary archives that consumers downloaded
25
+ on every `npm install`. Sizes vs. the manual recipe used for
26
+ v0.6.0:
27
+
28
+ | Platform | v0.7.0 (CI, unstripped) | v0.7.1 (CI, stripped) | Saving |
29
+ |---|---|---|---|
30
+ | iOS zip | 43 MB | ~26 MB | -17 MB |
31
+ | Android zip | 165 MB | ~42 MB | -123 MB |
32
+
33
+ The lib itself is unchanged; consumers on the `^0.7.0` semver range
34
+ automatically pick up v0.7.1 and start getting the smaller download.
35
+ No source-code changes; binary-only re-release.
36
+
37
+ #### Root cause
38
+
39
+ - **iOS**: `scripts/build-opencv-ios.sh` produced an xcframework
40
+ containing both the device slice (`ios-arm64`) and the simulator
41
+ slice (`ios-arm64_x86_64-simulator`). vision-camera + ARKit
42
+ don't work on the simulator and the example app targets devices
43
+ only, so the simulator slice was dead weight in every download.
44
+ - **Android**: `scripts/build-opencv-android.sh` ran OpenCV's
45
+ `build_sdk.py` for all four NDK ABIs (per the script's own
46
+ contract — produces a multi-arch fat SDK). The lib's
47
+ `android/build.gradle` sets `ndk.abiFilters arm64-v8a` so only
48
+ arm64-v8a binaries reach any consumer APK, but the zip carried
49
+ `armeabi-v7a` / `x86` / `x86_64` libs in three sibling dirs
50
+ (`sdk/native/libs/`, `staticlibs/`, `3rdparty/libs/`) plus
51
+ `samples/` (~10 MB) and `apk/` (~5 MB) — none of it ever loaded
52
+ at runtime.
53
+
54
+ #### Fix
55
+
56
+ Both build scripts now strip the dead-weight pieces immediately
57
+ after the OpenCV build completes, before zipping for upload.
58
+ Sentinel checks fail loudly if a strip removes the required
59
+ arm64-v8a artifacts (defends against a future refactor of the
60
+ strip block). Pattern matches the manual recipe in
61
+ `feedback_binary_release_packaging.md` (project memory).
62
+
63
+ The iOS strip auto-detects the simulator entry's index in the
64
+ xcframework's `Info.plist::AvailableLibraries` via a
65
+ `plutil -convert json | python3` one-liner — the index isn't fixed
66
+ across OpenCV builds and previous manual recipes that hardcoded
67
+ `AvailableLibraries.1` would have silently stripped the wrong
68
+ slice if the order changed.
69
+
70
+ #### Compatibility
71
+
72
+ Strict additive over v0.7.0. No code changes — the lib's runtime
73
+ and public API surface are byte-identical.
74
+
75
+ ## [0.7.0] — 2026-05-26
76
+
77
+ ### Added — Tier 1: `useKeyframeStream`
78
+
79
+ JS-thread subscription hook for **accepted-keyframe events** — the
80
+ small subset of camera frames the stitching engine actually chose to
81
+ include in the panorama. Foundation for plugin-pattern host features:
82
+ OCR on each saved keyframe, packet detection, server-side analysis,
83
+ analytics, etc.
84
+
85
+ Fires 4-6 times per panorama (once per accepted keyframe), NOT per
86
+ camera frame — the lowest-frequency, highest-value frame stream.
87
+
88
+ ```tsx
89
+ import { useKeyframeStream, type AcceptedKeyframe } from 'react-native-image-stitcher';
90
+
91
+ function OcrPlugin() {
92
+ useKeyframeStream(useCallback(async (kf: AcceptedKeyframe) => {
93
+ const text = await runOCR(kf.jpegPath);
94
+ console.log(`Keyframe ${kf.index} pose=${kf.pose.rotation}:`, text);
95
+ }, []));
96
+ return null;
97
+ }
98
+ ```
99
+
100
+ - **`useKeyframeStream(handler)`** exported from
101
+ `react-native-image-stitcher`. Subscribes to the existing
102
+ `IncrementalStateUpdate` event channel; surfaces accepted-keyframe
103
+ events through a typed callback. Re-subscribes on handler-identity
104
+ changes; async handler rejections are surfaced via `console.error`
105
+ rather than swallowed.
106
+ - **`AcceptedKeyframe` type** exported. Fields: `jpegPath` (absolute
107
+ path, no `file://` prefix); `pose` (rotation quaternion + optional
108
+ translation vector); `timestamp` (ms since epoch); `index`
109
+ (zero-based position in current panorama).
110
+ - **`IncrementalState.batchKeyframePose?`** + **`batchKeyframeAcceptedAtMs?`**
111
+ new optional fields. Populated by the native emit alongside the
112
+ existing `batchKeyframeThumbnailPath` + `batchKeyframeIndex` on
113
+ accept events. Direct readers of `IncrementalState` can consume
114
+ these without going through the new hook.
115
+
116
+ ### Changed (internal — externally invisible)
117
+
118
+ - **Native `emitBatchKeyframeAcceptedState` populates pose + timestamp.**
119
+ Both `IncrementalStitcher.swift::emitBatchKeyframeAcceptedState` and
120
+ `IncrementalStitcher.kt::emitBatchKeyframeAcceptedState` grew
121
+ parameters for the pose snapshot (quaternion + translation) and
122
+ accept-time wall-clock millis. The existing call sites in the
123
+ batch-keyframe accept path thread the pose they already have in
124
+ scope.
125
+
126
+ ### Engine-mode caveat
127
+
128
+ `useKeyframeStream` only fires under the `batch-keyframe` engine (the
129
+ `<Camera>` component's default). Live engines (`firstwins-rectilinear`,
130
+ `hybrid`, `slitscan-*`) paint into a live canvas instead of saving
131
+ per-accept JPEGs and do not surface accept events through this channel
132
+ — the hook silently does not fire in those modes. Live-engine accept
133
+ emit may land as a v0.7.1 follow-up if a real consumer needs it.
134
+
135
+ ### Translation semantics
136
+
137
+ `AcceptedKeyframe.pose.translation` is always populated by the native
138
+ emit. In AR mode it carries the real ARKit / ARCore camera transform
139
+ in metres (world coords). In non-AR (Frame Processor) mode the
140
+ translation reads as `[0, 0, 0]` because gyroscope provides only
141
+ rotation (no spatial anchor). Hosts that need to distinguish can
142
+ either check the active `frameSourceMode` or threshold the translation
143
+ magnitude.
144
+
145
+ ### Compatibility
146
+
147
+ Strict additive over v0.6.0. No host changes required. Existing
148
+ `subscribeIncrementalState` consumers see new optional fields but
149
+ their existing reads are unaffected.
150
+
151
+ ### Verification
152
+
153
+ - iPhone 17 Pro (real device, iOS 26.5): hold-and-release AR-mode
154
+ panorama produced four accepted-keyframe events with real pose
155
+ data (unit quaternion + non-zero translation in metres matching
156
+ the physical pan).
157
+ - Android (Galaxy A35): `compileDebugKotlin` BUILD SUCCESSFUL;
158
+ on-device runtime verification deferred for this release (the
159
+ Kotlin emit mirrors the iOS emit at the byte-for-byte payload
160
+ level — same field names, same types, same call-site pattern).
161
+
19
162
  ## [0.6.0] — 2026-05-25
20
163
 
21
164
  > [!WARNING]
@@ -1161,7 +1304,9 @@ Native module names also changed:
1161
1304
  - iOS pod: `RetaiLensCaptureSDK` → `RNImageStitcher`
1162
1305
  - iOS xcframework: shipped as `opencv2.xcframework` (linked from `RNImageStitcher.podspec`)
1163
1306
 
1164
- [Unreleased]: https://github.com/bhargavkanda/react-native-image-stitcher/compare/v0.6.0...HEAD
1307
+ [Unreleased]: https://github.com/bhargavkanda/react-native-image-stitcher/compare/v0.7.1...HEAD
1308
+ [0.7.1]: https://github.com/bhargavkanda/react-native-image-stitcher/compare/v0.7.0...v0.7.1
1309
+ [0.7.0]: https://github.com/bhargavkanda/react-native-image-stitcher/compare/v0.6.0...v0.7.0
1165
1310
  [0.6.0]: https://github.com/bhargavkanda/react-native-image-stitcher/compare/v0.5.1...v0.6.0
1166
1311
  [0.5.1]: https://github.com/bhargavkanda/react-native-image-stitcher/compare/v0.5.0...v0.5.1
1167
1312
  [0.5.0]: https://github.com/bhargavkanda/react-native-image-stitcher/compare/v0.4.1...v0.5.0
@@ -1174,6 +1174,15 @@ class IncrementalStitcher(
1174
1174
  keyframeMax = keyframeGate.maxCount,
1175
1175
  isLandscape = imageWidth >= imageHeight,
1176
1176
  newContentFraction = decision.newContentFraction,
1177
+ // v0.7.0 — Tier 1 hook: pose snapshot + accept timestamp
1178
+ // threaded through to JS via the existing state-update
1179
+ // channel. `tx,ty,tz,qx,qy,qz,qw` are parameters of
1180
+ // `ingestFromARCameraView`; in AR mode they're real
1181
+ // ARCore pose components, in non-AR mode they're
1182
+ // gyro-synthesised (translation ≈ 0).
1183
+ poseQx = qx, poseQy = qy, poseQz = qz, poseQw = qw,
1184
+ poseTx = tx, poseTy = ty, poseTz = tz,
1185
+ acceptedAtMs = System.currentTimeMillis(),
1177
1186
  )
1178
1187
  return
1179
1188
  }
@@ -2034,6 +2043,13 @@ class IncrementalStitcher(
2034
2043
  // "unknown" behaviour for call sites that don't have a
2035
2044
  // decision in hand.
2036
2045
  newContentFraction: Double,
2046
+ // v0.7.0 — Tier 1 hook fields. Pose is the AR pose at the
2047
+ // accept moment (gyro-synthesised in non-AR mode — translation
2048
+ // reads as ~zeros). `acceptedAtMs` is wall-clock ms since
2049
+ // Unix epoch; matches `Date.now()` on the JS side.
2050
+ poseQx: Double, poseQy: Double, poseQz: Double, poseQw: Double,
2051
+ poseTx: Double, poseTy: Double, poseTz: Double,
2052
+ acceptedAtMs: Long,
2037
2053
  ) {
2038
2054
  val state = Arguments.createMap()
2039
2055
  state.putNull("panoramaPath")
@@ -2063,6 +2079,25 @@ class IncrementalStitcher(
2063
2079
  // emitter).
2064
2080
  state.putString("batchKeyframeThumbnailPath", thumbnailPath)
2065
2081
  state.putInt("batchKeyframeIndex", keyframeIndex)
2082
+ // v0.7.0 — Tier 1 hook (useKeyframeStream) reads these. See
2083
+ // `AcceptedKeyframe` in src/stitching/incremental.ts. Translation
2084
+ // is always emitted; AR mode populates it from the camera
2085
+ // transform, non-AR mode reads ~zeros (gyro-only, no spatial
2086
+ // anchor).
2087
+ val pose = Arguments.createMap()
2088
+ val rotation = Arguments.createArray()
2089
+ rotation.pushDouble(poseQx)
2090
+ rotation.pushDouble(poseQy)
2091
+ rotation.pushDouble(poseQz)
2092
+ rotation.pushDouble(poseQw)
2093
+ pose.putArray("rotation", rotation)
2094
+ val translation = Arguments.createArray()
2095
+ translation.pushDouble(poseTx)
2096
+ translation.pushDouble(poseTy)
2097
+ translation.pushDouble(poseTz)
2098
+ pose.putArray("translation", translation)
2099
+ state.putMap("batchKeyframePose", pose)
2100
+ state.putDouble("batchKeyframeAcceptedAtMs", acceptedAtMs.toDouble())
2066
2101
  emitState(state)
2067
2102
  }
2068
2103
 
package/dist/index.d.ts CHANGED
@@ -62,8 +62,9 @@ export type { TakePhotoCallOptions } from './camera/useCapture';
62
62
  export { useVideoCapture } from './camera/useVideoCapture';
63
63
  export { useDeviceOrientation } from './camera/useDeviceOrientation';
64
64
  export { IncrementalOutcome, incrementalStitcherIsAvailable, subscribeIncrementalState, getIncrementalNativeModule, cleanupOldKeyframes, } from './stitching/incremental';
65
- export type { IncrementalState } from './stitching/incremental';
65
+ export type { IncrementalState, AcceptedKeyframe } from './stitching/incremental';
66
66
  export { useIncrementalStitcher } from './stitching/useIncrementalStitcher';
67
+ export { useKeyframeStream } from './stitching/useKeyframeStream';
67
68
  export { useFrameProcessorDriver } from './stitching/useFrameProcessorDriver';
68
69
  export type { UseFrameProcessorDriverOptions, FrameProcessorDriverHandle, } from './stitching/useFrameProcessorDriver';
69
70
  export { stitchVideo } from './stitching/stitchVideo';
package/dist/index.js CHANGED
@@ -22,7 +22,7 @@
22
22
  * adds RetaiLens-specific features on top.
23
23
  */
24
24
  Object.defineProperty(exports, "__esModule", { value: true });
25
- exports.stitchVideo = exports.useFrameProcessorDriver = exports.useIncrementalStitcher = exports.cleanupOldKeyframes = exports.getIncrementalNativeModule = exports.subscribeIncrementalState = exports.incrementalStitcherIsAvailable = exports.IncrementalOutcome = exports.useDeviceOrientation = exports.useVideoCapture = exports.useCapture = exports.ViewportCropOverlay = exports.hybridSettingsToNativeConfig = exports.slitscanSettingsToNativeConfig = exports.panoramaSettingsToNativeConfig = exports.DEFAULT_HYBRID_SETTINGS = exports.DEFAULT_SLITSCAN_SETTINGS = exports.DEFAULT_FLOW_GATE_SETTINGS = exports.DEFAULT_PANORAMA_SETTINGS = exports.PanoramaSettingsModal = exports.PanoramaGuidance = exports.PanoramaBandOverlay = exports.IncrementalPanGuide = exports.CaptureThumbnailStrip = exports.useStitchStatsToast = exports.CaptureStitchStatsToast = exports.CaptureOrientationPill = exports.CaptureKeyframePill = exports.CaptureMemoryPill = exports.CaptureDebugOverlay = exports.CaptureStatusOverlay = exports.CapturePreview = exports.CaptureControlsBar = exports.CaptureHeader = exports.CameraView = exports.ARCameraView = exports.useIMUTranslationGate = exports.ARTrackingState = exports.useARSession = exports.CameraError = exports.Camera = void 0;
25
+ exports.stitchVideo = exports.useFrameProcessorDriver = exports.useKeyframeStream = exports.useIncrementalStitcher = exports.cleanupOldKeyframes = exports.getIncrementalNativeModule = exports.subscribeIncrementalState = exports.incrementalStitcherIsAvailable = exports.IncrementalOutcome = exports.useDeviceOrientation = exports.useVideoCapture = exports.useCapture = exports.ViewportCropOverlay = exports.hybridSettingsToNativeConfig = exports.slitscanSettingsToNativeConfig = exports.panoramaSettingsToNativeConfig = exports.DEFAULT_HYBRID_SETTINGS = exports.DEFAULT_SLITSCAN_SETTINGS = exports.DEFAULT_FLOW_GATE_SETTINGS = exports.DEFAULT_PANORAMA_SETTINGS = exports.PanoramaSettingsModal = exports.PanoramaGuidance = exports.PanoramaBandOverlay = exports.IncrementalPanGuide = exports.CaptureThumbnailStrip = exports.useStitchStatsToast = exports.CaptureStitchStatsToast = exports.CaptureOrientationPill = exports.CaptureKeyframePill = exports.CaptureMemoryPill = exports.CaptureDebugOverlay = exports.CaptureStatusOverlay = exports.CapturePreview = exports.CaptureControlsBar = exports.CaptureHeader = exports.CameraView = exports.ARCameraView = exports.useIMUTranslationGate = exports.ARTrackingState = exports.useARSession = exports.CameraError = exports.Camera = void 0;
26
26
  // ─────────────────────────────────────────────────────────────────────
27
27
  // Layer 1 — the high-level <Camera> component
28
28
  // ─────────────────────────────────────────────────────────────────────
@@ -139,6 +139,12 @@ Object.defineProperty(exports, "getIncrementalNativeModule", { enumerable: true,
139
139
  Object.defineProperty(exports, "cleanupOldKeyframes", { enumerable: true, get: function () { return incremental_1.cleanupOldKeyframes; } });
140
140
  var useIncrementalStitcher_1 = require("./stitching/useIncrementalStitcher");
141
141
  Object.defineProperty(exports, "useIncrementalStitcher", { enumerable: true, get: function () { return useIncrementalStitcher_1.useIncrementalStitcher; } });
142
+ // v0.7.0 — Tier 1 subscriber API. Fires on each accepted keyframe
143
+ // in batch-keyframe captures (see hook's docstring for engine-mode
144
+ // caveat). Foundation for plugin-pattern host features (OCR per
145
+ // keyframe, packet detection, server-side analysis, etc.).
146
+ var useKeyframeStream_1 = require("./stitching/useKeyframeStream");
147
+ Object.defineProperty(exports, "useKeyframeStream", { enumerable: true, get: function () { return useKeyframeStream_1.useKeyframeStream; } });
142
148
  // vision-camera Frame Processor driver for non-AR captures. As
143
149
  // of v0.6 the only non-AR driver exported (the legacy
144
150
  // `useIncrementalJSDriver` was removed; was deprecated in v0.5).
@@ -54,6 +54,48 @@ export declare enum IncrementalOutcome {
54
54
  */
55
55
  SkippedKeyframeMaxReached = 9
56
56
  }
57
+ /**
58
+ * v0.7.0 (Tier 1) — public payload type for an accepted keyframe.
59
+ * Delivered to subscribers of the `useKeyframeStream` hook.
60
+ *
61
+ * Emits once per keyframe accepted by the stitching engine — typically
62
+ * 4-6 times per panorama, not per camera frame. Use for low-frequency
63
+ * per-keyframe host work (OCR on the saved JPEG, packet detection,
64
+ * server-side analysis, analytics, etc.).
65
+ *
66
+ * Caveat: only the `batch-keyframe` engine emits these events as of
67
+ * v0.7.0. Live engines (`firstwins-rectilinear`, `hybrid`,
68
+ * `slitscan-*`) paint into a live canvas instead of saving per-accept
69
+ * JPEGs and do not currently surface accept events through this
70
+ * channel; the hook silently does not fire there. A v0.7.1 follow-up
71
+ * may add live-engine accept emit if a real consumer needs it.
72
+ *
73
+ * The JPEG at `jpegPath` is the engine's own copy under the active
74
+ * capture's session directory. It persists for the lifetime of the
75
+ * panorama and is cleaned up automatically when the panorama finalises
76
+ * or is abandoned (or via explicit `cleanupKeyframes`). Host code
77
+ * wanting to retain it long-term must copy synchronously inside the
78
+ * handler.
79
+ */
80
+ export interface AcceptedKeyframe {
81
+ /** Absolute filesystem path to the keyframe JPEG. No `file://` prefix. */
82
+ jpegPath: string;
83
+ /**
84
+ * Pose snapshot at the moment of acceptance. Quaternion
85
+ * convention: `(x, y, z, w)`; lib uses
86
+ * `q = q_yaw * q_pitch * q_roll`. Translation in metres (world
87
+ * coords) is present in AR mode and undefined in non-AR mode (no
88
+ * spatial anchor — only gyro-derived rotation is available).
89
+ */
90
+ pose: {
91
+ rotation: [number, number, number, number];
92
+ translation?: [number, number, number];
93
+ };
94
+ /** Milliseconds since the Unix epoch when the engine accepted this keyframe. */
95
+ timestamp: number;
96
+ /** Zero-based index of this keyframe within the in-progress panorama. */
97
+ index: number;
98
+ }
57
99
  export interface IncrementalState {
58
100
  /**
59
101
  * Path to the latest panorama snapshot JPEG (file path, no
@@ -145,6 +187,33 @@ export interface IncrementalState {
145
187
  * for the thumbnail strip.
146
188
  */
147
189
  batchKeyframeIndex?: number;
190
+ /**
191
+ * v0.7.0 (Tier 1) — pose snapshot at the moment the engine
192
+ * accepted this keyframe. Populated alongside
193
+ * `batchKeyframeThumbnailPath` + `batchKeyframeIndex` on the
194
+ * keyframe-accepted state emit from the `batch-keyframe` engine.
195
+ * Undefined for other engines and for non-accept events.
196
+ *
197
+ * Quaternion convention: `(x, y, z, w)`; lib uses
198
+ * `q = q_yaw * q_pitch * q_roll`. AR mode populates `translation`
199
+ * from the AR camera transform (metres, world coords). Non-AR
200
+ * mode omits `translation` (no spatial anchor — only gyro-derived
201
+ * rotation is available).
202
+ *
203
+ * Foundation for the `useKeyframeStream` Tier 1 host hook.
204
+ */
205
+ batchKeyframePose?: {
206
+ rotation: [number, number, number, number];
207
+ translation?: [number, number, number];
208
+ };
209
+ /**
210
+ * v0.7.0 (Tier 1) — monotonic timestamp (milliseconds since the
211
+ * Unix epoch) when the engine accepted this keyframe. Populated
212
+ * alongside the other `batchKeyframe*` fields on the
213
+ * keyframe-accepted emit. Undefined for other engines and for
214
+ * non-accept events.
215
+ */
216
+ batchKeyframeAcceptedAtMs?: number;
148
217
  /**
149
218
  * 2026-05-16 — realtime+batch fusion (Option A "Replace on
150
219
  * completion"). True between the moment a hybrid-engine
@@ -0,0 +1,69 @@
1
+ import { type AcceptedKeyframe } from './incremental';
2
+ /**
3
+ * v0.7.0 — Tier 1: subscribe to accepted-keyframe events while a
4
+ * panorama is in progress.
5
+ *
6
+ * Fires once per keyframe accepted by the stitching engine — typically
7
+ * 4-6 times per panorama, NOT per camera frame. Use for low-frequency
8
+ * per-keyframe host work such as OCR on the saved JPEG, packet
9
+ * detection, server-side analysis, or analytics.
10
+ *
11
+ * For mid-frequency frame access (sampled stream), see `useFrameStream`
12
+ * (v0.9.0+). For per-frame worklet access (~30 Hz), see
13
+ * `useFrameProcessor` (v0.8.0+).
14
+ *
15
+ * ## Engine-mode caveat (v0.7.0)
16
+ *
17
+ * Only the `batch-keyframe` engine emits these events. Live engines
18
+ * (`firstwins-rectilinear`, `hybrid`, `slitscan-*`) paint into a live
19
+ * canvas instead of saving per-accept JPEGs, and do not surface accept
20
+ * events through this channel — the hook silently does not fire when
21
+ * such an engine is active. A v0.7.1 follow-up may add live-engine
22
+ * accept emit if a real consumer needs it.
23
+ *
24
+ * ## Payload
25
+ *
26
+ * The handler receives an {@link AcceptedKeyframe}:
27
+ *
28
+ * - `jpegPath`: absolute filesystem path, no `file://` prefix. The
29
+ * JPEG is the engine's own copy under the active capture's session
30
+ * directory. It persists for the lifetime of the panorama and is
31
+ * cleaned up automatically when the panorama finalises or is
32
+ * abandoned (or via explicit `cleanupOldKeyframes`). Copy
33
+ * synchronously inside the handler if long-term retention is
34
+ * needed.
35
+ * - `pose`: rotation quaternion (always present) + optional
36
+ * translation vector (populated in AR mode; undefined in non-AR).
37
+ * - `timestamp`: milliseconds since the Unix epoch.
38
+ * - `index`: zero-based keyframe position in the current panorama.
39
+ *
40
+ * ## Lifecycle
41
+ *
42
+ * Re-subscribes on `handler` identity changes. Wrap the handler in
43
+ * `useCallback` if it closes over state or props you don't want to
44
+ * trigger re-subscription on every render.
45
+ *
46
+ * Async handlers are fire-and-forget. Rejected promises are caught
47
+ * and logged via `console.error`; no backpressure on the native side.
48
+ * Host code wanting to serialise work across keyframes should manage
49
+ * that itself (e.g., push into a queue + worker).
50
+ *
51
+ * ## Example
52
+ *
53
+ * ```tsx
54
+ * import { useCallback } from 'react';
55
+ * import { useKeyframeStream } from 'react-native-image-stitcher';
56
+ *
57
+ * function OcrPlugin() {
58
+ * useKeyframeStream(
59
+ * useCallback(async (kf) => {
60
+ * const text = await runOCR(kf.jpegPath);
61
+ * console.log(`Keyframe ${kf.index} pose=${kf.pose.rotation}:`, text);
62
+ * }, []),
63
+ * );
64
+ * return null;
65
+ * }
66
+ * ```
67
+ */
68
+ export declare function useKeyframeStream(handler: (keyframe: AcceptedKeyframe) => void | Promise<void>): void;
69
+ //# sourceMappingURL=useKeyframeStream.d.ts.map
@@ -0,0 +1,120 @@
1
+ "use strict";
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.useKeyframeStream = useKeyframeStream;
5
+ const react_1 = require("react");
6
+ const incremental_1 = require("./incremental");
7
+ /**
8
+ * v0.7.0 — Tier 1: subscribe to accepted-keyframe events while a
9
+ * panorama is in progress.
10
+ *
11
+ * Fires once per keyframe accepted by the stitching engine — typically
12
+ * 4-6 times per panorama, NOT per camera frame. Use for low-frequency
13
+ * per-keyframe host work such as OCR on the saved JPEG, packet
14
+ * detection, server-side analysis, or analytics.
15
+ *
16
+ * For mid-frequency frame access (sampled stream), see `useFrameStream`
17
+ * (v0.9.0+). For per-frame worklet access (~30 Hz), see
18
+ * `useFrameProcessor` (v0.8.0+).
19
+ *
20
+ * ## Engine-mode caveat (v0.7.0)
21
+ *
22
+ * Only the `batch-keyframe` engine emits these events. Live engines
23
+ * (`firstwins-rectilinear`, `hybrid`, `slitscan-*`) paint into a live
24
+ * canvas instead of saving per-accept JPEGs, and do not surface accept
25
+ * events through this channel — the hook silently does not fire when
26
+ * such an engine is active. A v0.7.1 follow-up may add live-engine
27
+ * accept emit if a real consumer needs it.
28
+ *
29
+ * ## Payload
30
+ *
31
+ * The handler receives an {@link AcceptedKeyframe}:
32
+ *
33
+ * - `jpegPath`: absolute filesystem path, no `file://` prefix. The
34
+ * JPEG is the engine's own copy under the active capture's session
35
+ * directory. It persists for the lifetime of the panorama and is
36
+ * cleaned up automatically when the panorama finalises or is
37
+ * abandoned (or via explicit `cleanupOldKeyframes`). Copy
38
+ * synchronously inside the handler if long-term retention is
39
+ * needed.
40
+ * - `pose`: rotation quaternion (always present) + optional
41
+ * translation vector (populated in AR mode; undefined in non-AR).
42
+ * - `timestamp`: milliseconds since the Unix epoch.
43
+ * - `index`: zero-based keyframe position in the current panorama.
44
+ *
45
+ * ## Lifecycle
46
+ *
47
+ * Re-subscribes on `handler` identity changes. Wrap the handler in
48
+ * `useCallback` if it closes over state or props you don't want to
49
+ * trigger re-subscription on every render.
50
+ *
51
+ * Async handlers are fire-and-forget. Rejected promises are caught
52
+ * and logged via `console.error`; no backpressure on the native side.
53
+ * Host code wanting to serialise work across keyframes should manage
54
+ * that itself (e.g., push into a queue + worker).
55
+ *
56
+ * ## Example
57
+ *
58
+ * ```tsx
59
+ * import { useCallback } from 'react';
60
+ * import { useKeyframeStream } from 'react-native-image-stitcher';
61
+ *
62
+ * function OcrPlugin() {
63
+ * useKeyframeStream(
64
+ * useCallback(async (kf) => {
65
+ * const text = await runOCR(kf.jpegPath);
66
+ * console.log(`Keyframe ${kf.index} pose=${kf.pose.rotation}:`, text);
67
+ * }, []),
68
+ * );
69
+ * return null;
70
+ * }
71
+ * ```
72
+ */
73
+ function useKeyframeStream(handler) {
74
+ (0, react_1.useEffect)(() => {
75
+ const sub = (0, incremental_1.subscribeIncrementalState)((state) => {
76
+ // The `batch-keyframe` engine emits four optional fields together
77
+ // on accept events. Non-accept emits (snapshot updates,
78
+ // refinement progress, live-engine state ticks, etc.) leave
79
+ // `batchKeyframeThumbnailPath` undefined — that's our
80
+ // accept-event sentinel.
81
+ const jpegPath = state.batchKeyframeThumbnailPath;
82
+ const index = state.batchKeyframeIndex;
83
+ if (jpegPath === undefined || index === undefined) {
84
+ return;
85
+ }
86
+ // `batchKeyframePose` + `batchKeyframeAcceptedAtMs` are
87
+ // populated alongside the path + index by the post-v0.7.0
88
+ // native emit. Defensive defaults guard against a host
89
+ // running on a slightly-older native binary (e.g., during a
90
+ // partial upgrade) — identity quaternion + `Date.now()`.
91
+ // Published v0.7.0 native always populates both.
92
+ const pose = state.batchKeyframePose ?? {
93
+ rotation: [0, 0, 0, 1],
94
+ };
95
+ const timestamp = state.batchKeyframeAcceptedAtMs ?? Date.now();
96
+ const keyframe = {
97
+ jpegPath,
98
+ pose,
99
+ timestamp,
100
+ index,
101
+ };
102
+ // Fire-and-forget. Async handler rejections are surfaced via
103
+ // console.error so they don't disappear into the void.
104
+ const result = handler(keyframe);
105
+ if (result && typeof result.catch === 'function') {
106
+ result.catch((err) => {
107
+ // eslint-disable-next-line no-console
108
+ console.error('[useKeyframeStream] handler threw:', err);
109
+ });
110
+ }
111
+ });
112
+ // `subscribeIncrementalState` returns null when the native module
113
+ // isn't linked (Expo Go, unit tests without the bridge, etc.).
114
+ // In that case we have nothing to clean up.
115
+ if (sub === null)
116
+ return;
117
+ return () => sub.remove();
118
+ }, [handler]);
119
+ }
120
+ //# sourceMappingURL=useKeyframeStream.js.map
@@ -2160,7 +2160,13 @@ public final class IncrementalStitcher: NSObject {
2160
2160
  keyframeIndex: Int,
2161
2161
  keyframeCount: Int,
2162
2162
  keyframeMax: Int,
2163
- isLandscape: Bool
2163
+ isLandscape: Bool,
2164
+ // v0.7.0 — Tier 1 hook fields. `pose` is the AR pose at the
2165
+ // accept moment (gyro-synthesised in non-AR mode — translation
2166
+ // will read as ~zeros). `acceptedAtMs` is wall-clock ms since
2167
+ // Unix epoch; matches `Date.now()` on the JS side.
2168
+ pose: RNSARFramePose,
2169
+ acceptedAtMs: Double
2164
2170
  ) {
2165
2171
  let state = IncrementalStateObject(
2166
2172
  panoramaPath: nil,
@@ -2184,6 +2190,16 @@ public final class IncrementalStitcher: NSObject {
2184
2190
  // carry — JS reads these directly from the userInfo blob.
2185
2191
  dict["batchKeyframeThumbnailPath"] = thumbnailPath
2186
2192
  dict["batchKeyframeIndex"] = keyframeIndex
2193
+ // v0.7.0 — Tier 1 hook (useKeyframeStream) reads these. See
2194
+ // `AcceptedKeyframe` in src/stitching/incremental.ts. Translation
2195
+ // is always emitted; AR mode populates it from the camera
2196
+ // transform, non-AR mode reads ~zeros (gyro-only, no spatial
2197
+ // anchor).
2198
+ dict["batchKeyframePose"] = [
2199
+ "rotation": [pose.qx, pose.qy, pose.qz, pose.qw],
2200
+ "translation": [pose.tx, pose.ty, pose.tz],
2201
+ ] as [String: Any]
2202
+ dict["batchKeyframeAcceptedAtMs"] = acceptedAtMs
2187
2203
  NotificationCenter.default.post(
2188
2204
  name: .retailensIncrementalStateUpdate,
2189
2205
  object: nil,
@@ -2637,7 +2653,12 @@ public final class IncrementalStitcher: NSObject {
2637
2653
  keyframeIndex: Int(record.index),
2638
2654
  keyframeCount: count,
2639
2655
  keyframeMax: self.keyframeGate.maxCount,
2640
- isLandscape: pose.imageWidth >= pose.imageHeight
2656
+ isLandscape: pose.imageWidth >= pose.imageHeight,
2657
+ // v0.7.0 — Tier 1 hook: pose snapshot + accept
2658
+ // timestamp threaded through to JS via the
2659
+ // existing state-update channel.
2660
+ pose: pose,
2661
+ acceptedAtMs: Date().timeIntervalSince1970 * 1000
2641
2662
  )
2642
2663
  } catch let err as NSError {
2643
2664
  os_log(.fault, log: Self.diagLog,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-image-stitcher",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
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",
package/src/index.ts CHANGED
@@ -175,8 +175,13 @@ export {
175
175
  getIncrementalNativeModule,
176
176
  cleanupOldKeyframes,
177
177
  } from './stitching/incremental';
178
- export type { IncrementalState } from './stitching/incremental';
178
+ export type { IncrementalState, AcceptedKeyframe } from './stitching/incremental';
179
179
  export { useIncrementalStitcher } from './stitching/useIncrementalStitcher';
180
+ // v0.7.0 — Tier 1 subscriber API. Fires on each accepted keyframe
181
+ // in batch-keyframe captures (see hook's docstring for engine-mode
182
+ // caveat). Foundation for plugin-pattern host features (OCR per
183
+ // keyframe, packet detection, server-side analysis, etc.).
184
+ export { useKeyframeStream } from './stitching/useKeyframeStream';
180
185
  // vision-camera Frame Processor driver for non-AR captures. As
181
186
  // of v0.6 the only non-AR driver exported (the legacy
182
187
  // `useIncrementalJSDriver` was removed; was deprecated in v0.5).
@@ -61,6 +61,53 @@ export enum IncrementalOutcome {
61
61
  }
62
62
 
63
63
 
64
+ /**
65
+ * v0.7.0 (Tier 1) — public payload type for an accepted keyframe.
66
+ * Delivered to subscribers of the `useKeyframeStream` hook.
67
+ *
68
+ * Emits once per keyframe accepted by the stitching engine — typically
69
+ * 4-6 times per panorama, not per camera frame. Use for low-frequency
70
+ * per-keyframe host work (OCR on the saved JPEG, packet detection,
71
+ * server-side analysis, analytics, etc.).
72
+ *
73
+ * Caveat: only the `batch-keyframe` engine emits these events as of
74
+ * v0.7.0. Live engines (`firstwins-rectilinear`, `hybrid`,
75
+ * `slitscan-*`) paint into a live canvas instead of saving per-accept
76
+ * JPEGs and do not currently surface accept events through this
77
+ * channel; the hook silently does not fire there. A v0.7.1 follow-up
78
+ * may add live-engine accept emit if a real consumer needs it.
79
+ *
80
+ * The JPEG at `jpegPath` is the engine's own copy under the active
81
+ * capture's session directory. It persists for the lifetime of the
82
+ * panorama and is cleaned up automatically when the panorama finalises
83
+ * or is abandoned (or via explicit `cleanupKeyframes`). Host code
84
+ * wanting to retain it long-term must copy synchronously inside the
85
+ * handler.
86
+ */
87
+ export interface AcceptedKeyframe {
88
+ /** Absolute filesystem path to the keyframe JPEG. No `file://` prefix. */
89
+ jpegPath: string;
90
+
91
+ /**
92
+ * Pose snapshot at the moment of acceptance. Quaternion
93
+ * convention: `(x, y, z, w)`; lib uses
94
+ * `q = q_yaw * q_pitch * q_roll`. Translation in metres (world
95
+ * coords) is present in AR mode and undefined in non-AR mode (no
96
+ * spatial anchor — only gyro-derived rotation is available).
97
+ */
98
+ pose: {
99
+ rotation: [number, number, number, number];
100
+ translation?: [number, number, number];
101
+ };
102
+
103
+ /** Milliseconds since the Unix epoch when the engine accepted this keyframe. */
104
+ timestamp: number;
105
+
106
+ /** Zero-based index of this keyframe within the in-progress panorama. */
107
+ index: number;
108
+ }
109
+
110
+
64
111
  export interface IncrementalState {
65
112
  /**
66
113
  * Path to the latest panorama snapshot JPEG (file path, no
@@ -152,6 +199,33 @@ export interface IncrementalState {
152
199
  * for the thumbnail strip.
153
200
  */
154
201
  batchKeyframeIndex?: number;
202
+ /**
203
+ * v0.7.0 (Tier 1) — pose snapshot at the moment the engine
204
+ * accepted this keyframe. Populated alongside
205
+ * `batchKeyframeThumbnailPath` + `batchKeyframeIndex` on the
206
+ * keyframe-accepted state emit from the `batch-keyframe` engine.
207
+ * Undefined for other engines and for non-accept events.
208
+ *
209
+ * Quaternion convention: `(x, y, z, w)`; lib uses
210
+ * `q = q_yaw * q_pitch * q_roll`. AR mode populates `translation`
211
+ * from the AR camera transform (metres, world coords). Non-AR
212
+ * mode omits `translation` (no spatial anchor — only gyro-derived
213
+ * rotation is available).
214
+ *
215
+ * Foundation for the `useKeyframeStream` Tier 1 host hook.
216
+ */
217
+ batchKeyframePose?: {
218
+ rotation: [number, number, number, number];
219
+ translation?: [number, number, number];
220
+ };
221
+ /**
222
+ * v0.7.0 (Tier 1) — monotonic timestamp (milliseconds since the
223
+ * Unix epoch) when the engine accepted this keyframe. Populated
224
+ * alongside the other `batchKeyframe*` fields on the
225
+ * keyframe-accepted emit. Undefined for other engines and for
226
+ * non-accept events.
227
+ */
228
+ batchKeyframeAcceptedAtMs?: number;
155
229
  /**
156
230
  * 2026-05-16 — realtime+batch fusion (Option A "Replace on
157
231
  * completion"). True between the moment a hybrid-engine
@@ -0,0 +1,127 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+
3
+ import { useEffect } from 'react';
4
+
5
+ import {
6
+ subscribeIncrementalState,
7
+ type AcceptedKeyframe,
8
+ type IncrementalState,
9
+ } from './incremental';
10
+
11
+ /**
12
+ * v0.7.0 — Tier 1: subscribe to accepted-keyframe events while a
13
+ * panorama is in progress.
14
+ *
15
+ * Fires once per keyframe accepted by the stitching engine — typically
16
+ * 4-6 times per panorama, NOT per camera frame. Use for low-frequency
17
+ * per-keyframe host work such as OCR on the saved JPEG, packet
18
+ * detection, server-side analysis, or analytics.
19
+ *
20
+ * For mid-frequency frame access (sampled stream), see `useFrameStream`
21
+ * (v0.9.0+). For per-frame worklet access (~30 Hz), see
22
+ * `useFrameProcessor` (v0.8.0+).
23
+ *
24
+ * ## Engine-mode caveat (v0.7.0)
25
+ *
26
+ * Only the `batch-keyframe` engine emits these events. Live engines
27
+ * (`firstwins-rectilinear`, `hybrid`, `slitscan-*`) paint into a live
28
+ * canvas instead of saving per-accept JPEGs, and do not surface accept
29
+ * events through this channel — the hook silently does not fire when
30
+ * such an engine is active. A v0.7.1 follow-up may add live-engine
31
+ * accept emit if a real consumer needs it.
32
+ *
33
+ * ## Payload
34
+ *
35
+ * The handler receives an {@link AcceptedKeyframe}:
36
+ *
37
+ * - `jpegPath`: absolute filesystem path, no `file://` prefix. The
38
+ * JPEG is the engine's own copy under the active capture's session
39
+ * directory. It persists for the lifetime of the panorama and is
40
+ * cleaned up automatically when the panorama finalises or is
41
+ * abandoned (or via explicit `cleanupOldKeyframes`). Copy
42
+ * synchronously inside the handler if long-term retention is
43
+ * needed.
44
+ * - `pose`: rotation quaternion (always present) + optional
45
+ * translation vector (populated in AR mode; undefined in non-AR).
46
+ * - `timestamp`: milliseconds since the Unix epoch.
47
+ * - `index`: zero-based keyframe position in the current panorama.
48
+ *
49
+ * ## Lifecycle
50
+ *
51
+ * Re-subscribes on `handler` identity changes. Wrap the handler in
52
+ * `useCallback` if it closes over state or props you don't want to
53
+ * trigger re-subscription on every render.
54
+ *
55
+ * Async handlers are fire-and-forget. Rejected promises are caught
56
+ * and logged via `console.error`; no backpressure on the native side.
57
+ * Host code wanting to serialise work across keyframes should manage
58
+ * that itself (e.g., push into a queue + worker).
59
+ *
60
+ * ## Example
61
+ *
62
+ * ```tsx
63
+ * import { useCallback } from 'react';
64
+ * import { useKeyframeStream } from 'react-native-image-stitcher';
65
+ *
66
+ * function OcrPlugin() {
67
+ * useKeyframeStream(
68
+ * useCallback(async (kf) => {
69
+ * const text = await runOCR(kf.jpegPath);
70
+ * console.log(`Keyframe ${kf.index} pose=${kf.pose.rotation}:`, text);
71
+ * }, []),
72
+ * );
73
+ * return null;
74
+ * }
75
+ * ```
76
+ */
77
+ export function useKeyframeStream(
78
+ handler: (keyframe: AcceptedKeyframe) => void | Promise<void>,
79
+ ): void {
80
+ useEffect(() => {
81
+ const sub = subscribeIncrementalState((state: IncrementalState) => {
82
+ // The `batch-keyframe` engine emits four optional fields together
83
+ // on accept events. Non-accept emits (snapshot updates,
84
+ // refinement progress, live-engine state ticks, etc.) leave
85
+ // `batchKeyframeThumbnailPath` undefined — that's our
86
+ // accept-event sentinel.
87
+ const jpegPath = state.batchKeyframeThumbnailPath;
88
+ const index = state.batchKeyframeIndex;
89
+ if (jpegPath === undefined || index === undefined) {
90
+ return;
91
+ }
92
+
93
+ // `batchKeyframePose` + `batchKeyframeAcceptedAtMs` are
94
+ // populated alongside the path + index by the post-v0.7.0
95
+ // native emit. Defensive defaults guard against a host
96
+ // running on a slightly-older native binary (e.g., during a
97
+ // partial upgrade) — identity quaternion + `Date.now()`.
98
+ // Published v0.7.0 native always populates both.
99
+ const pose = state.batchKeyframePose ?? {
100
+ rotation: [0, 0, 0, 1] as [number, number, number, number],
101
+ };
102
+ const timestamp = state.batchKeyframeAcceptedAtMs ?? Date.now();
103
+
104
+ const keyframe: AcceptedKeyframe = {
105
+ jpegPath,
106
+ pose,
107
+ timestamp,
108
+ index,
109
+ };
110
+
111
+ // Fire-and-forget. Async handler rejections are surfaced via
112
+ // console.error so they don't disappear into the void.
113
+ const result = handler(keyframe);
114
+ if (result && typeof (result as Promise<void>).catch === 'function') {
115
+ (result as Promise<void>).catch((err) => {
116
+ // eslint-disable-next-line no-console
117
+ console.error('[useKeyframeStream] handler threw:', err);
118
+ });
119
+ }
120
+ });
121
+ // `subscribeIncrementalState` returns null when the native module
122
+ // isn't linked (Expo Go, unit tests without the bridge, etc.).
123
+ // In that case we have nothing to clean up.
124
+ if (sub === null) return;
125
+ return () => sub.remove();
126
+ }, [handler]);
127
+ }