react-native-image-stitcher 0.6.0 → 0.7.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.
package/CHANGELOG.md CHANGED
@@ -16,6 +16,93 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
16
16
 
17
17
  ## [Unreleased]
18
18
 
19
+ ## [0.7.0] — 2026-05-26
20
+
21
+ ### Added — Tier 1: `useKeyframeStream`
22
+
23
+ JS-thread subscription hook for **accepted-keyframe events** — the
24
+ small subset of camera frames the stitching engine actually chose to
25
+ include in the panorama. Foundation for plugin-pattern host features:
26
+ OCR on each saved keyframe, packet detection, server-side analysis,
27
+ analytics, etc.
28
+
29
+ Fires 4-6 times per panorama (once per accepted keyframe), NOT per
30
+ camera frame — the lowest-frequency, highest-value frame stream.
31
+
32
+ ```tsx
33
+ import { useKeyframeStream, type AcceptedKeyframe } from 'react-native-image-stitcher';
34
+
35
+ function OcrPlugin() {
36
+ useKeyframeStream(useCallback(async (kf: AcceptedKeyframe) => {
37
+ const text = await runOCR(kf.jpegPath);
38
+ console.log(`Keyframe ${kf.index} pose=${kf.pose.rotation}:`, text);
39
+ }, []));
40
+ return null;
41
+ }
42
+ ```
43
+
44
+ - **`useKeyframeStream(handler)`** exported from
45
+ `react-native-image-stitcher`. Subscribes to the existing
46
+ `IncrementalStateUpdate` event channel; surfaces accepted-keyframe
47
+ events through a typed callback. Re-subscribes on handler-identity
48
+ changes; async handler rejections are surfaced via `console.error`
49
+ rather than swallowed.
50
+ - **`AcceptedKeyframe` type** exported. Fields: `jpegPath` (absolute
51
+ path, no `file://` prefix); `pose` (rotation quaternion + optional
52
+ translation vector); `timestamp` (ms since epoch); `index`
53
+ (zero-based position in current panorama).
54
+ - **`IncrementalState.batchKeyframePose?`** + **`batchKeyframeAcceptedAtMs?`**
55
+ new optional fields. Populated by the native emit alongside the
56
+ existing `batchKeyframeThumbnailPath` + `batchKeyframeIndex` on
57
+ accept events. Direct readers of `IncrementalState` can consume
58
+ these without going through the new hook.
59
+
60
+ ### Changed (internal — externally invisible)
61
+
62
+ - **Native `emitBatchKeyframeAcceptedState` populates pose + timestamp.**
63
+ Both `IncrementalStitcher.swift::emitBatchKeyframeAcceptedState` and
64
+ `IncrementalStitcher.kt::emitBatchKeyframeAcceptedState` grew
65
+ parameters for the pose snapshot (quaternion + translation) and
66
+ accept-time wall-clock millis. The existing call sites in the
67
+ batch-keyframe accept path thread the pose they already have in
68
+ scope.
69
+
70
+ ### Engine-mode caveat
71
+
72
+ `useKeyframeStream` only fires under the `batch-keyframe` engine (the
73
+ `<Camera>` component's default). Live engines (`firstwins-rectilinear`,
74
+ `hybrid`, `slitscan-*`) paint into a live canvas instead of saving
75
+ per-accept JPEGs and do not surface accept events through this channel
76
+ — the hook silently does not fire in those modes. Live-engine accept
77
+ emit may land as a v0.7.1 follow-up if a real consumer needs it.
78
+
79
+ ### Translation semantics
80
+
81
+ `AcceptedKeyframe.pose.translation` is always populated by the native
82
+ emit. In AR mode it carries the real ARKit / ARCore camera transform
83
+ in metres (world coords). In non-AR (Frame Processor) mode the
84
+ translation reads as `[0, 0, 0]` because gyroscope provides only
85
+ rotation (no spatial anchor). Hosts that need to distinguish can
86
+ either check the active `frameSourceMode` or threshold the translation
87
+ magnitude.
88
+
89
+ ### Compatibility
90
+
91
+ Strict additive over v0.6.0. No host changes required. Existing
92
+ `subscribeIncrementalState` consumers see new optional fields but
93
+ their existing reads are unaffected.
94
+
95
+ ### Verification
96
+
97
+ - iPhone 17 Pro (real device, iOS 26.5): hold-and-release AR-mode
98
+ panorama produced four accepted-keyframe events with real pose
99
+ data (unit quaternion + non-zero translation in metres matching
100
+ the physical pan).
101
+ - Android (Galaxy A35): `compileDebugKotlin` BUILD SUCCESSFUL;
102
+ on-device runtime verification deferred for this release (the
103
+ Kotlin emit mirrors the iOS emit at the byte-for-byte payload
104
+ level — same field names, same types, same call-site pattern).
105
+
19
106
  ## [0.6.0] — 2026-05-25
20
107
 
21
108
  > [!WARNING]
@@ -1161,7 +1248,8 @@ Native module names also changed:
1161
1248
  - iOS pod: `RetaiLensCaptureSDK` → `RNImageStitcher`
1162
1249
  - iOS xcframework: shipped as `opencv2.xcframework` (linked from `RNImageStitcher.podspec`)
1163
1250
 
1164
- [Unreleased]: https://github.com/bhargavkanda/react-native-image-stitcher/compare/v0.6.0...HEAD
1251
+ [Unreleased]: https://github.com/bhargavkanda/react-native-image-stitcher/compare/v0.7.0...HEAD
1252
+ [0.7.0]: https://github.com/bhargavkanda/react-native-image-stitcher/compare/v0.6.0...v0.7.0
1165
1253
  [0.6.0]: https://github.com/bhargavkanda/react-native-image-stitcher/compare/v0.5.1...v0.6.0
1166
1254
  [0.5.1]: https://github.com/bhargavkanda/react-native-image-stitcher/compare/v0.5.0...v0.5.1
1167
1255
  [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.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",
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
+ }