react-native-image-stitcher 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/CHANGELOG.md +246 -0
  2. package/android/build.gradle +10 -0
  3. package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +115 -10
  4. package/cpp/stitcher_worklet_registry.cpp +10 -0
  5. package/cpp/stitcher_worklet_registry.hpp +10 -0
  6. package/cpp/tests/CMakeLists.txt +98 -0
  7. package/cpp/tests/README.md +86 -0
  8. package/cpp/tests/pose_test.cpp +74 -0
  9. package/cpp/tests/stitcher_frame_data_test.cpp +132 -0
  10. package/cpp/tests/stitcher_worklet_registry_test.cpp +195 -0
  11. package/cpp/tests/stubs/jsi/jsi.h +33 -0
  12. package/cpp/tests/stubs/react-native-worklets-core/WKTJsiWorklet.h +34 -0
  13. package/dist/camera/Camera.d.ts +30 -14
  14. package/dist/camera/Camera.js +18 -18
  15. package/dist/camera/useCapture.d.ts +1 -1
  16. package/dist/camera/useCapture.js +1 -1
  17. package/dist/index.d.ts +2 -0
  18. package/dist/index.js +9 -1
  19. package/dist/stitching/incremental.d.ts +41 -0
  20. package/dist/stitching/useFrameProcessorDriver.d.ts +50 -95
  21. package/dist/stitching/useFrameProcessorDriver.js +76 -294
  22. package/dist/stitching/useFrameStream.js +52 -37
  23. package/dist/stitching/useStitcherWorklet.d.ts +185 -0
  24. package/dist/stitching/useStitcherWorklet.js +275 -0
  25. package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +138 -9
  26. package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.swift +50 -14
  27. package/package.json +1 -1
  28. package/src/camera/Camera.tsx +48 -32
  29. package/src/camera/useCapture.ts +1 -1
  30. package/src/index.ts +13 -0
  31. package/src/stitching/__tests__/subscribeIncrementalState.refine.test.ts +276 -0
  32. package/src/stitching/incremental.ts +42 -0
  33. package/src/stitching/useFrameProcessorDriver.ts +79 -320
  34. package/src/stitching/useFrameStream.ts +55 -39
  35. package/src/stitching/useStitcherWorklet.ts +390 -0
@@ -0,0 +1,276 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Unit + integration coverage for the v0.10.0 PR B refine-progress
4
+ * lifecycle on the `IncrementalStateUpdate` channel.
5
+ *
6
+ * What this test is for:
7
+ *
8
+ * - Contract: native emits a 4-stage sequence
9
+ * `validating → stitching → writing → done` (and the failure
10
+ * variant `validating → error`) with `refineStage` /
11
+ * `refineProgress` / `refineFrames` / `refineError` keys.
12
+ * - Regression: catches a future renamer of any of those keys
13
+ * (subscribeIncrementalState would silently deliver `undefined`
14
+ * for the missing fields, and the host's progress pill would
15
+ * stop rendering — exactly the bug class we hit on iOS in PR B
16
+ * before the bridgeless-interop fix).
17
+ *
18
+ * What this test is NOT for:
19
+ *
20
+ * - Exercising the real native bridge — RCTEventEmitter under RN
21
+ * bridgeless interop can only be tested on-device. The bug we
22
+ * fixed in PR B (sendEvent silently no-ops for certain body
23
+ * shapes) is verified via the manual smoke test recorded in
24
+ * CHANGELOG.md "Fixed — v0.10.0 PR B (iOS)". This file pins
25
+ * the JS-side contract that bridge fix has to satisfy.
26
+ *
27
+ * Mock surface: per-test `jest.mock('react-native', ...)` so the
28
+ * shared `jest.mocks/react-native.js` stays minimal (per the comment
29
+ * in that file). We stub `NativeModules.IncrementalStitcher` and
30
+ * `NativeEventEmitter` together because `subscribeIncrementalState`
31
+ * wires them together internally.
32
+ */
33
+
34
+ import type { IncrementalState } from '../incremental';
35
+
36
+ // Hand-rolled event-emitter fake we can drive synchronously from
37
+ // tests. Modelled on RN's NativeEventEmitter shape: addListener
38
+ // returns an object with a `.remove()` method.
39
+ type Listener = (state: IncrementalState) => void;
40
+
41
+ class FakeNativeEventEmitter {
42
+ private listeners: Map<string, Set<Listener>> = new Map();
43
+
44
+ constructor(_nativeModule: unknown) {
45
+ // No-op: real RN reads addListener/removeListeners off the
46
+ // native module for the listener-count contract; we don't.
47
+ }
48
+
49
+ addListener(eventType: string, listener: Listener) {
50
+ let set = this.listeners.get(eventType);
51
+ if (!set) {
52
+ set = new Set();
53
+ this.listeners.set(eventType, set);
54
+ }
55
+ set.add(listener);
56
+ return {
57
+ remove: () => {
58
+ set!.delete(listener);
59
+ },
60
+ };
61
+ }
62
+
63
+ // Test-only helper: drive an event into all subscribers.
64
+ _emit(eventType: string, state: IncrementalState) {
65
+ const set = this.listeners.get(eventType);
66
+ if (!set) return;
67
+ for (const listener of set) {
68
+ listener(state);
69
+ }
70
+ }
71
+ }
72
+
73
+ // Shared emitter handle the per-test setup writes its asserts
74
+ // against. The mock factory below has to construct via `new`, so we
75
+ // stash the latest instance here for the test to drive.
76
+ let lastEmitter: FakeNativeEventEmitter | null = null;
77
+
78
+ jest.mock('react-native', () => ({
79
+ NativeModules: {
80
+ IncrementalStitcher: {
81
+ // RCTEventEmitter / NativeEventEmitter contract — RN's runtime
82
+ // calls these when JS subscribes / unsubscribes so the native
83
+ // side can track listener count. We just stub them.
84
+ addListener: jest.fn(),
85
+ removeListeners: jest.fn(),
86
+ },
87
+ },
88
+ NativeEventEmitter: jest.fn().mockImplementation((nativeModule: unknown) => {
89
+ lastEmitter = new FakeNativeEventEmitter(nativeModule);
90
+ return lastEmitter;
91
+ }),
92
+ Platform: { OS: 'ios', select: (spec: { ios?: unknown; default?: unknown }) => spec.ios ?? spec.default },
93
+ }));
94
+
95
+ // Import AFTER jest.mock so the SUT picks up the mocked module.
96
+ import { subscribeIncrementalState } from '../incremental';
97
+
98
+ // Build the base state shape the native side emits — matches the
99
+ // fields IncrementalStateObject.asDictionary() includes on iOS and
100
+ // Arguments.createMap() includes on Android.
101
+ function makeBaseState(): IncrementalState {
102
+ return {
103
+ width: 1920,
104
+ height: 1080,
105
+ acceptedCount: 3,
106
+ outcome: 8, // acceptedHigh
107
+ confidence: 0.92,
108
+ overlapPercent: 18.5,
109
+ processingMs: 0,
110
+ isLandscape: false,
111
+ paintedExtent: 1920,
112
+ panExtent: 1920,
113
+ keyframeMax: 0,
114
+ } as IncrementalState;
115
+ }
116
+
117
+ describe('subscribeIncrementalState — refine progress lifecycle (v0.10.0 PR B)', () => {
118
+ beforeEach(() => {
119
+ lastEmitter = null;
120
+ jest.clearAllMocks();
121
+ });
122
+
123
+ it('returns null when the native IncrementalStitcher module is missing', () => {
124
+ // Temporarily blank the module.
125
+ const RN = jest.requireMock('react-native') as { NativeModules: Record<string, unknown> };
126
+ const saved = RN.NativeModules.IncrementalStitcher;
127
+ RN.NativeModules.IncrementalStitcher = undefined;
128
+ try {
129
+ expect(subscribeIncrementalState(() => {})).toBeNull();
130
+ } finally {
131
+ RN.NativeModules.IncrementalStitcher = saved;
132
+ }
133
+ });
134
+
135
+ it('returns an EmitterSubscription when subscribed; remove() stops delivery', () => {
136
+ const events: IncrementalState[] = [];
137
+ const sub = subscribeIncrementalState((s) => events.push(s));
138
+ expect(sub).not.toBeNull();
139
+ expect(lastEmitter).not.toBeNull();
140
+
141
+ lastEmitter!._emit('IncrementalStateUpdate', {
142
+ ...makeBaseState(),
143
+ refineStage: 'validating',
144
+ refineProgress: 0.05,
145
+ refineFrames: 3,
146
+ } as IncrementalState);
147
+ expect(events).toHaveLength(1);
148
+
149
+ sub!.remove();
150
+ lastEmitter!._emit('IncrementalStateUpdate', {
151
+ ...makeBaseState(),
152
+ refineStage: 'done',
153
+ refineProgress: 1.0,
154
+ refineFrames: 3,
155
+ } as IncrementalState);
156
+ expect(events).toHaveLength(1); // unchanged after remove()
157
+ });
158
+
159
+ it('happy-path: delivers validating → stitching → writing → done in order with correct refineStage', () => {
160
+ const stages: Array<{ stage: string | undefined; progress: number | undefined }> = [];
161
+ subscribeIncrementalState((s) => {
162
+ stages.push({ stage: s.refineStage, progress: s.refineProgress });
163
+ });
164
+
165
+ const sequence: Array<Pick<IncrementalState, 'refineStage' | 'refineProgress' | 'refineFrames'>> = [
166
+ { refineStage: 'validating', refineProgress: 0.05, refineFrames: 3 },
167
+ { refineStage: 'stitching', refineProgress: 0.10, refineFrames: 3 },
168
+ { refineStage: 'writing', refineProgress: 0.90, refineFrames: 3 },
169
+ { refineStage: 'done', refineProgress: 1.00, refineFrames: 3 },
170
+ ];
171
+ for (const ev of sequence) {
172
+ lastEmitter!._emit('IncrementalStateUpdate', { ...makeBaseState(), ...ev } as IncrementalState);
173
+ }
174
+
175
+ expect(stages).toEqual([
176
+ { stage: 'validating', progress: 0.05 },
177
+ { stage: 'stitching', progress: 0.10 },
178
+ { stage: 'writing', progress: 0.90 },
179
+ { stage: 'done', progress: 1.00 },
180
+ ]);
181
+ });
182
+
183
+ it('refineProgress is non-decreasing across the happy-path sequence (monotonicity contract)', () => {
184
+ const progresses: number[] = [];
185
+ subscribeIncrementalState((s) => {
186
+ if (s.refineProgress !== undefined) progresses.push(s.refineProgress);
187
+ });
188
+
189
+ for (const p of [0.05, 0.10, 0.90, 1.00]) {
190
+ lastEmitter!._emit('IncrementalStateUpdate', {
191
+ ...makeBaseState(),
192
+ refineStage: 'stitching', // stage is irrelevant for this assertion
193
+ refineProgress: p,
194
+ refineFrames: 3,
195
+ } as IncrementalState);
196
+ }
197
+
198
+ expect(progresses).toEqual([0.05, 0.10, 0.90, 1.00]);
199
+ for (let i = 1; i < progresses.length; i++) {
200
+ expect(progresses[i]).toBeGreaterThanOrEqual(progresses[i - 1]);
201
+ }
202
+ });
203
+
204
+ it('failure-path: validating → error carries refineError; no further stages emitted', () => {
205
+ const events: IncrementalState[] = [];
206
+ subscribeIncrementalState((s) => events.push(s));
207
+
208
+ lastEmitter!._emit('IncrementalStateUpdate', {
209
+ ...makeBaseState(),
210
+ refineStage: 'validating',
211
+ refineProgress: 0.05,
212
+ refineFrames: 3,
213
+ } as IncrementalState);
214
+ lastEmitter!._emit('IncrementalStateUpdate', {
215
+ ...makeBaseState(),
216
+ refineStage: 'error',
217
+ refineProgress: 1.0,
218
+ refineFrames: 3,
219
+ refineError: 'INVALID_FRAMES: missing JPEG at index 1',
220
+ } as IncrementalState);
221
+
222
+ expect(events).toHaveLength(2);
223
+ expect(events[0].refineStage).toBe('validating');
224
+ expect(events[1].refineStage).toBe('error');
225
+ expect(events[1].refineError).toBe('INVALID_FRAMES: missing JPEG at index 1');
226
+ // refineError is absent on the validating event.
227
+ expect(events[0].refineError).toBeUndefined();
228
+ });
229
+
230
+ it('refineFrames passes through unchanged (regression guard for key rename)', () => {
231
+ const seen: Array<number | undefined> = [];
232
+ subscribeIncrementalState((s) => seen.push(s.refineFrames));
233
+
234
+ for (const n of [3, 5, 8]) {
235
+ lastEmitter!._emit('IncrementalStateUpdate', {
236
+ ...makeBaseState(),
237
+ refineStage: 'stitching',
238
+ refineProgress: 0.5,
239
+ refineFrames: n,
240
+ } as IncrementalState);
241
+ }
242
+ expect(seen).toEqual([3, 5, 8]);
243
+ });
244
+
245
+ it('live (non-refine) state events leave refine fields undefined', () => {
246
+ // Asserts that the contract is "refine fields are only populated
247
+ // during a refine call" — so the example app's `if
248
+ // (s.refineStage === undefined) return;` short-circuit is sound.
249
+ const events: IncrementalState[] = [];
250
+ subscribeIncrementalState((s) => events.push(s));
251
+
252
+ lastEmitter!._emit('IncrementalStateUpdate', makeBaseState());
253
+ expect(events).toHaveLength(1);
254
+ expect(events[0].refineStage).toBeUndefined();
255
+ expect(events[0].refineProgress).toBeUndefined();
256
+ expect(events[0].refineFrames).toBeUndefined();
257
+ expect(events[0].refineError).toBeUndefined();
258
+ });
259
+
260
+ it('subscribes on the correct channel name "IncrementalStateUpdate" (cross-platform contract)', () => {
261
+ // If anyone renames the event constant on either side, the
262
+ // subscriber stops receiving events. Pin the literal here.
263
+ let receivedOnRight = false;
264
+ let receivedOnWrong = false;
265
+ subscribeIncrementalState(() => {
266
+ receivedOnRight = true;
267
+ });
268
+ // Fire on a deliberately-wrong channel — should NOT deliver.
269
+ lastEmitter!._emit('SomeOtherChannel', makeBaseState());
270
+ expect(receivedOnRight).toBe(false);
271
+ expect(receivedOnWrong).toBe(false);
272
+ // Fire on the right channel — should deliver.
273
+ lastEmitter!._emit('IncrementalStateUpdate', makeBaseState());
274
+ expect(receivedOnRight).toBe(true);
275
+ });
276
+ });
@@ -261,6 +261,48 @@ export interface IncrementalState {
261
261
  * keyframes on disk.
262
262
  */
263
263
  refinedPanoramaPath?: string;
264
+
265
+ /**
266
+ * v0.10.0 (#15A) — current phase of an in-flight `refinePanorama`
267
+ * call. Fires from both the explicit `module.refinePanorama(...)`
268
+ * JS API path AND the hybrid-engine auto-refine path (which calls
269
+ * the same native refinePanorama internally).
270
+ *
271
+ * Lifecycle:
272
+ * - `"validating"` (fraction 0.05) — synchronous input checks
273
+ * - `"stitching"` (fraction 0.10) — OpenCV stitch in flight
274
+ * - `"writing"` (fraction 0.90) — stitch done, JPEG written
275
+ * - `"done"` (fraction 1.00) — success
276
+ * - `"error"` (fraction 1.00) — failure; `refineError` is set
277
+ *
278
+ * Coarse on purpose: OpenCV's Stitcher doesn't expose mid-pipeline
279
+ * progress, so the 0.10 → 0.90 jump is one opaque step. Use
280
+ * `refineStage` for a stage label; use `refineProgress` purely for
281
+ * spinner progress.
282
+ *
283
+ * Undefined when no refinement is in flight.
284
+ */
285
+ refineStage?: 'validating' | 'stitching' | 'writing' | 'done' | 'error';
286
+ /**
287
+ * v0.10.0 (#15A) — coarse progress fraction in `[0, 1]` aligned
288
+ * with `refineStage`. See `refineStage` for the per-stage value
289
+ * mapping. Undefined when no refinement is in flight.
290
+ */
291
+ refineProgress?: number;
292
+ /**
293
+ * v0.10.0 (#15A) — number of input frames the in-flight refine is
294
+ * processing. Useful for the UI label
295
+ * (`Stitching 6 frames…`). Mirrors the `framesRequested` field
296
+ * returned in the explicit refinePanorama resolution. Undefined
297
+ * when no refinement is in flight.
298
+ */
299
+ refineFrames?: number;
300
+ /**
301
+ * v0.10.0 (#15A) — present only when `refineStage === 'error'`.
302
+ * Human-readable error message; the same text the rejected promise
303
+ * carries. Use to render a one-line failure pill.
304
+ */
305
+ refineError?: string;
264
306
  }
265
307
 
266
308