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
package/CHANGELOG.md CHANGED
@@ -16,6 +16,228 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
16
16
 
17
17
  ## [Unreleased]
18
18
 
19
+ ## [0.11.0] — 2026-05-28
20
+
21
+ ### Added — `useStitcherWorklet` for non-AR composition
22
+
23
+ Closes the v0.8.0 Phase 5 either-or constraint: hosts that want to
24
+ write their OWN `useFrameProcessor` worklet body can now COMPOSE
25
+ first-party stitching back in with a single `stitcher.call(frame)`
26
+ call, instead of having to choose between their worklet and the
27
+ lib's stitching.
28
+
29
+ ```tsx
30
+ import {
31
+ Camera, useFrameProcessor, useStitcherWorklet,
32
+ type StitcherFrame,
33
+ } from 'react-native-image-stitcher';
34
+
35
+ function MyScreen() {
36
+ const stitcher = useStitcherWorklet();
37
+ const fp = useFrameProcessor((frame: StitcherFrame) => {
38
+ 'worklet';
39
+ hostPreLogic(frame);
40
+ stitcher.call(frame); // ← first-party stitching
41
+ hostPostLogic(frame);
42
+ }, [stitcher.call]);
43
+ return <Camera frameProcessor={fp} ... />;
44
+ }
45
+ ```
46
+
47
+ Migrating from v0.10.x is a one-line diff:
48
+
49
+ ```diff
50
+ + const stitcher = useStitcherWorklet();
51
+ const fp = useFrameProcessor((frame: StitcherFrame) => {
52
+ 'worklet';
53
+ + stitcher.call(frame); // ← first-party stitching back in
54
+ hostLogic(frame);
55
+ - }, [hostLogic]);
56
+ + }, [stitcher.call, hostLogic]);
57
+ ```
58
+
59
+ ### Changed
60
+
61
+ - `useFrameProcessorDriver` is now a thin wrapper around
62
+ `useStitcherWorklet`. Public API (`start` / `stop` /
63
+ `isRunning` / `frameProcessor`) is unchanged. Pose-reset
64
+ semantics preserved via the new `stitcher.reset()` method which
65
+ the driver calls internally from `start()` and `stop()`.
66
+ - The gyro subscription that powers pose tracking now lives in
67
+ `useStitcherWorklet` and runs for the lifetime of the hook
68
+ (mount → unmount) rather than being tied to the driver's
69
+ `start()` / `stop()`. In practice this matches all observed
70
+ host integrations (capture screens mount `<Camera>` for the
71
+ duration of capture; idle screens don't). Battery delta is
72
+ small (≪1% CPU at 33 ms gyro sampling).
73
+ - `<Camera frameProcessor>` JSDoc rewritten: the "Non-AR mode
74
+ tradeoff (HONEST)" section is replaced by a "Non-AR mode
75
+ composition" section that shows the v0.11.0 composition
76
+ pattern. The runtime `console.info` text is softened from
77
+ "your worklet REPLACES first-party stitching, panorama capture
78
+ will not produce stitched output" to "if you want first-party
79
+ stitching alongside, call `useStitcherWorklet()` from your
80
+ worklet body".
81
+ - Example app (`example/App.tsx`) now demonstrates the
82
+ composition pattern end-to-end: one
83
+ `useFrameProcessor` body that calls both `stitcher.call(frame)`
84
+ and the existing 1 Hz host tick log. `<Camera>` mounts with
85
+ `frameProcessor={exampleFrameProcessor}` (previously left
86
+ unwired with an "intentionally unused" comment block).
87
+ - `docs/frame-access-tiers.md` adds a `useStitcherWorklet`
88
+ reference section + 1-line migration diff. Softens the
89
+ "either-or" language in the Tier 3 + AR-vs-non-AR sections.
90
+
91
+ ### Files changed
92
+ - NEW: `src/stitching/useStitcherWorklet.ts`
93
+ - `src/stitching/useFrameProcessorDriver.ts` (refactored thin wrapper)
94
+ - `src/index.ts` (export new hook + types)
95
+ - `src/camera/Camera.tsx` (docstring + console.info softened)
96
+ - `example/App.tsx` (composition demo)
97
+ - `docs/frame-access-tiers.md` (new section + softened wording)
98
+ - `docs/v0.11.0-manual-verification-checklist.md` (Phase 4 human-loop checklist)
99
+
100
+ ### Not touched
101
+ - All native code (`ios/Sources/`, `android/src/main/cpp/`,
102
+ `android/src/main/java/io/imagestitcher/rn/`) — pure TS refactor.
103
+ - AR-mode dispatch path — already composes natively.
104
+ - `useFrameProcessor` (v0.8.0 public hook) — unchanged.
105
+
106
+ ### Verified
107
+ - JS Jest: **69 / 69 pass**
108
+ - C++ Gtest: **17 / 17 pass**
109
+ - Android JUnit: **6 / 6 pass**
110
+ - iOS build (Debug, generic iOS device): clean
111
+ - Android `:app:assembleDebug`: clean
112
+ - Real-device panorama capture verification deferred to the
113
+ human-in-the-loop checklist (`docs/v0.11.0-manual-verification-checklist.md`).
114
+
115
+ ## [0.10.0] — 2026-05-28
116
+
117
+ ### Added — v0.10.0 PR A: host-side test infrastructure (`#9A` + `#11A`)
118
+
119
+ Two parallel test harnesses landed so future tech-debt PRs in the
120
+ v0.10.0 sweep (and beyond) can pin invariants without standing up a
121
+ device build per change.
122
+
123
+ #### Shared C++ Google Test runner (`#9A`)
124
+
125
+ - `cpp/tests/CMakeLists.txt` — standalone CMake project that fetches
126
+ Google Test `v1.14.0` via `FetchContent` and compiles a single
127
+ `stitcher_cpp_tests` executable. No system-wide gtest install
128
+ required.
129
+ - `scripts/run-cpp-tests.sh` — one-shot configure / build / `ctest`
130
+ driver. Output lands under gitignored `build/cpp-tests/`.
131
+ - Initial suite (17 cases):
132
+ - `Pose` / `PlaneTransform` POD layout, size, field-offset
133
+ invariants (pinned to the cross-platform marshalling contract
134
+ in `cpp/ar_frame_pose.h`).
135
+ - `StitcherFrameData` default-construction invariants the JSI
136
+ host-object `get()` dispatch depends on (e.g. `qw=1.0`,
137
+ `hasTranslation=false`).
138
+ - `PixelBufferReader` `copyTo` clipping contract — validated via
139
+ a `FakePixelBufferReader` test helper.
140
+ - `StitcherWorkletRegistry` lifecycle: shared-instance, install
141
+ /uninstall/count/snapshot, snapshot independence from later
142
+ mutations, concurrent installs (16 threads × 32 each) yield
143
+ unique IDs without lock contention bugs.
144
+ - New test-only registry seam `_installEntryForTests(invoker)` (in
145
+ `cpp/stitcher_worklet_registry.{hpp,cpp}`) — mirrors the existing
146
+ `_resetForTests` pattern. Bypasses the JSI runtime path so tests
147
+ don't need Hermes + worklets-core; `nullptr` invokers are safe
148
+ because the registry never dereferences them.
149
+ - JSI / worklets-core stubs under `cpp/tests/stubs/` let
150
+ `stitcher_worklet_registry.cpp` compile in the host-side test
151
+ target without pulling in React Native's JSI headers or the
152
+ worklets-core library. Stubs are scoped exclusively to the test
153
+ include path; production builds never see them.
154
+ - See `cpp/tests/README.md` for the strategy + a list of what's
155
+ deferred to v0.11.0+ (KeyframeGate / OpenCV-dependent code; JSI
156
+ host-object dispatch).
157
+
158
+ #### Android JUnit scaffold (`#11A`)
159
+
160
+ - `android/build.gradle` — adds `testImplementation
161
+ "junit:junit:4.13.2"`. Minimal — only JUnit 4 (matches AGP's
162
+ default test runner).
163
+ - `android/src/test/java/io/imagestitcher/rn/TransferredNV21Test.kt`
164
+ — 6 tests covering the v0.10.0 `TransferredNV21` single-use
165
+ ownership wrapper: constructor empty/non-empty, takeOnce returns
166
+ the original reference, takeOnce throws on second call, thread-
167
+ safe single-winner under 16-thread contention, distinct wrappers
168
+ are independent.
169
+ - Run via `./gradlew :react-native-image-stitcher:testDebugUnitTest`.
170
+
171
+ Neither suite changes runtime behaviour — both are additive test
172
+ infrastructure.
173
+
174
+ ### Added — v0.10.0 PR B: `refinePanorama` progress events + cleanup audit (`#15A` + `#16C`)
175
+
176
+ #### `#15A` — phase-milestone progress emit from `refinePanorama`
177
+
178
+ `refinePanorama` (both the explicit JS `module.refinePanorama(...)` API
179
+ and the hybrid-engine auto-refine path that calls it internally) now
180
+ emits coarse phase events on the existing `IncrementalStateUpdate`
181
+ device-event channel. Five stages cover one refine lifetime:
182
+
183
+ | Stage | `refineProgress` | When |
184
+ | ------------- | ---------------- | ------------------------------------ |
185
+ | `validating` | 0.05 | start of method, before any I/O |
186
+ | `stitching` | 0.10 | OpenCV stitch in flight |
187
+ | `writing` | 0.90 | stitch returned, JPEG written |
188
+ | `done` | 1.00 | success — promise about to resolve |
189
+ | `error` | 1.00 | failure — `refineError` is set |
190
+
191
+ `refineStage` carries the stage string; `refineProgress` carries the
192
+ fraction; `refineFrames` reports the input keyframe count; `refineError`
193
+ is populated on the failure path so the host can render a one-line
194
+ failure pill.
195
+
196
+ Coarse on purpose: OpenCV's `Stitcher` doesn't expose mid-pipeline
197
+ progress, so the `0.10 → 0.90` jump is one opaque step. JS uses
198
+ `refineStage` for the UI label and `refineProgress` purely for the
199
+ spinner.
200
+
201
+ Reuses the existing channel (no second listener wiring required).
202
+ Existing JS consumers that don't read the new fields are unaffected.
203
+
204
+ #### `#16C` — moderate cleanup audit sweep
205
+
206
+ - `src/camera/useCapture.ts` — removed a stale "`useVideoCapture` (TODO)"
207
+ reference; the hook has existed since v0.4.
208
+ - `ios/Sources/RNImageStitcher/IncrementalStitcherBridge.swift` — removed
209
+ a self-flagged "remove this comment after" reference left over from a
210
+ past PiP investigation.
211
+ - `console.*` audit: every call in `src/` was reviewed; all 13 are
212
+ legitimate (warn/error for surfaceable failures; `console.info`
213
+ one-shots that document known tradeoffs). No removals needed.
214
+ - TODO/FIXME triage: 4 remaining own-code TODOs all reference tracked
215
+ future work (lens-probe follow-up, shared-stitcher-port-part-2,
216
+ EXIF writer). Left in place.
217
+ - `ts-prune`: 3 surface-level orphans (`PanoramaConfirmModal`,
218
+ `IncrementalStitcherView`, `stitchFrames`) are intentional public
219
+ deep-import API; not re-exported from `src/index.ts` but
220
+ documented and consumed by hosts. Left in place.
221
+
222
+ No production behaviour changed — these are docstring + dead-comment
223
+ removals only.
224
+
225
+ ### Fixed — v0.10.0 PR B (iOS): refine state events not reaching JS under RN bridgeless interop
226
+
227
+ Switched `IncrementalStitcherBridge` state-event delivery from
228
+ `RCTEventEmitter.sendEvent` to `bridge.enqueueJSCall("RCTDeviceEventEmitter", "emit", ...)`.
229
+ Root cause: under RN bridgeless interop (RN 0.84), `sendEvent`
230
+ silently no-ops for some event-body shapes even when the bridge is
231
+ non-nil and the listener count is > 0 — refine events with the
232
+ `refineStage` / `refineProgress` / `refineFrames` keys were not
233
+ reaching any JS subscriber while live state events with a smaller
234
+ body shape on the same channel were. Also defensively
235
+ `removeObserver` before `addObserver` in `init()` so the
236
+ NotificationCenter registration is idempotent if RN re-invokes
237
+ `init()` on the same instance (also observed on bridgeless interop).
238
+ Android is unaffected — Android's bridge already emits via
239
+ `DeviceEventManagerModule.RCTDeviceEventEmitter.emit(...)` directly.
240
+
19
241
  ## [0.9.0] — 2026-05-27
20
242
 
21
243
  ### Added — layered frame-access helpers
@@ -125,6 +347,30 @@ the Layer 1 + 2 + 3 pipeline working end-to-end on both iPhone
125
347
  - New hooks return `useFrameProcessor`-shape objects compatible
126
348
  with `<Camera frameProcessor={...}>` (Phase 5 from v0.8.0).
127
349
 
350
+ ### Known limitations (v0.9.0 — addressed in v0.11.0)
351
+
352
+ - **Layer 3 `useFrameStream` in AR mode**: the Layer 1
353
+ `save_frame_as_jpeg` vc Frame Processor plugin expects a vision-
354
+ camera `Frame` with `.buffer = CMSampleBufferRef`. In AR mode
355
+ the worklet receives a `StitcherFrameHostObject` (v0.8.0
356
+ Phase 4b's JSI host object) without `.buffer` — the plugin call
357
+ returns `{ok: false, ...}` and `useFrameStream` silently skips
358
+ the sample. Hosts needing per-frame native processing in AR
359
+ mode should use **Layer 2 (`useThrottledFrameProcessor`)** —
360
+ it works in both modes and is the right primitive for the
361
+ worklet-native use cases listed in `docs/frame-access-tiers.md`
362
+ (OCR via Vision/ML Kit, TFLite ML detection, LiDAR depth).
363
+ AR-mode Layer 3 support tracked in v0.11.0's plan
364
+ (`docs/plans/2026-05-27-v0.11.0-non-ar-composition.md`) —
365
+ bundled with `useStitcherWorklet` since both extend the
366
+ `__stitcherProxy` host-function infrastructure.
367
+ - **Layer 3 `useFrameStream` in non-AR mode**: wiring the host's
368
+ frameProcessor through `<Camera>` displaces the lib's
369
+ first-party stitching driver (the documented Phase 5 either-or
370
+ constraint from v0.8.0). Non-AR panorama capture won't produce
371
+ stitched output while a host frameProcessor is wired. Tracked
372
+ in v0.11.0 (`useStitcherWorklet` composition).
373
+
128
374
  ### Notes
129
375
 
130
376
  - Formal SSIM parity gate (Phase 7 of the v0.9.0 plan) was NOT
@@ -282,6 +282,16 @@ dependencies {
282
282
  if (findProject(':react-native-worklets-core') != null) {
283
283
  implementation project(':react-native-worklets-core')
284
284
  }
285
+
286
+ // v0.10.0 audit #11A — Android JUnit test scaffold. JVM unit
287
+ // tests for pure-Kotlin data wrappers + algorithm helpers that
288
+ // don't need an Android device. Run via
289
+ // `gradlew :react-native-image-stitcher:test`.
290
+ //
291
+ // Kept minimal — only JUnit 4 (matches AGP's default test
292
+ // runner). Hosts that want to add their own android-test
293
+ // dependencies can do so independently.
294
+ testImplementation "junit:junit:4.13.2"
285
295
  }
286
296
 
287
297
  // Helper from the React Native gradle convention to read host-app
@@ -1673,12 +1673,26 @@ class IncrementalStitcher(
1673
1673
  @ReactMethod
1674
1674
  fun refinePanorama(options: ReadableMap, promise: Promise) {
1675
1675
  val framePathsArr = options.getArray("framePaths")
1676
+ val requestedCount = framePathsArr?.size() ?: 0
1677
+ // v0.10.0 #15A — emit `validating` at the very top so JS sees
1678
+ // refine activity even when validation fails fast. Frames may
1679
+ // be empty here; report whatever the caller asked for.
1680
+ emitRefineProgress(
1681
+ stage = "validating",
1682
+ fraction = 0.05,
1683
+ frames = requestedCount,
1684
+ errorMessage = null,
1685
+ )
1676
1686
  if (framePathsArr == null || framePathsArr.size() < 2) {
1677
- promise.reject(
1678
- "incremental-refine-invalid-input",
1679
- "refinePanorama requires at least 2 framePaths (got " +
1680
- "${framePathsArr?.size() ?: 0}).",
1687
+ val msg = "refinePanorama requires at least 2 framePaths (got " +
1688
+ "$requestedCount)."
1689
+ emitRefineProgress(
1690
+ stage = "error",
1691
+ fraction = 1.0,
1692
+ frames = requestedCount,
1693
+ errorMessage = msg,
1681
1694
  )
1695
+ promise.reject("incremental-refine-invalid-input", msg)
1682
1696
  return
1683
1697
  }
1684
1698
  val framePaths = Array(framePathsArr.size()) {
@@ -1686,10 +1700,14 @@ class IncrementalStitcher(
1686
1700
  }
1687
1701
  val outputPathOpt = options.getString("outputPath")
1688
1702
  if (outputPathOpt.isNullOrEmpty()) {
1689
- promise.reject(
1690
- "incremental-refine-invalid-input",
1691
- "refinePanorama requires a non-empty outputPath.",
1703
+ val msg = "refinePanorama requires a non-empty outputPath."
1704
+ emitRefineProgress(
1705
+ stage = "error",
1706
+ fraction = 1.0,
1707
+ frames = framePaths.size,
1708
+ errorMessage = msg,
1692
1709
  )
1710
+ promise.reject("incremental-refine-invalid-input", msg)
1693
1711
  return
1694
1712
  }
1695
1713
  val outputPath = stripFileScheme(outputPathOpt)
@@ -1709,16 +1727,26 @@ class IncrementalStitcher(
1709
1727
  // Pre-flight existence check — same defensive layer iOS has.
1710
1728
  for (p in framePaths) {
1711
1729
  if (!File(p).exists()) {
1712
- promise.reject(
1713
- "incremental-refine-missing-keyframe",
1714
- "refinePanorama: keyframe missing on disk — $p",
1730
+ val msg = "refinePanorama: keyframe missing on disk — $p"
1731
+ emitRefineProgress(
1732
+ stage = "error",
1733
+ fraction = 1.0,
1734
+ frames = framePaths.size,
1735
+ errorMessage = msg,
1715
1736
  )
1737
+ promise.reject("incremental-refine-missing-keyframe", msg)
1716
1738
  return
1717
1739
  }
1718
1740
  }
1719
1741
 
1720
1742
  refineScope.launch {
1721
1743
  try {
1744
+ emitRefineProgress(
1745
+ stage = "stitching",
1746
+ fraction = 0.1,
1747
+ frames = framePaths.size,
1748
+ errorMessage = null,
1749
+ )
1722
1750
  val stitcher = BatchStitcher.bridgeInstance
1723
1751
  ?: throw IllegalStateException(
1724
1752
  "BatchStitcher.bridgeInstance is null — " +
@@ -1742,6 +1770,18 @@ class IncrementalStitcher(
1742
1770
  useInscribedRectCrop,
1743
1771
  stitchMode = effectiveMode,
1744
1772
  )
1773
+ // Stitch returned — BatchStitcher writes the JPEG
1774
+ // synchronously, so "writing" reflects the final
1775
+ // assembly + file I/O cost (which has already been
1776
+ // paid by this point in practice). Emit so JS can
1777
+ // flip its label from "Stitching" to "Writing"
1778
+ // before the done event fires.
1779
+ emitRefineProgress(
1780
+ stage = "writing",
1781
+ fraction = 0.9,
1782
+ frames = framePaths.size,
1783
+ errorMessage = null,
1784
+ )
1745
1785
  val framesRequested =
1746
1786
  if (dims.size > 2) dims[2] else framePaths.size
1747
1787
  val framesIncluded =
@@ -1757,8 +1797,20 @@ class IncrementalStitcher(
1757
1797
  putInt("framesDropped", framesRequested - framesIncluded)
1758
1798
  putDouble("finalConfidenceThresh", finalConfidenceThresh)
1759
1799
  }
1800
+ emitRefineProgress(
1801
+ stage = "done",
1802
+ fraction = 1.0,
1803
+ frames = framePaths.size,
1804
+ errorMessage = null,
1805
+ )
1760
1806
  promise.resolve(map)
1761
1807
  } catch (t: Throwable) {
1808
+ emitRefineProgress(
1809
+ stage = "error",
1810
+ fraction = 1.0,
1811
+ frames = framePaths.size,
1812
+ errorMessage = t.message ?: t.javaClass.simpleName,
1813
+ )
1762
1814
  promise.reject("incremental-refine-failed", t.message, t)
1763
1815
  }
1764
1816
  }
@@ -1883,6 +1935,59 @@ class IncrementalStitcher(
1883
1935
  emitState(state)
1884
1936
  }
1885
1937
 
1938
+ /**
1939
+ * v0.10.0 #15A — emit a refine-pipeline phase update on the same
1940
+ * `IncrementalStateUpdate` channel that carries `isRefining` /
1941
+ * `refinedPanoramaPath`. Five `stage` values fire across the
1942
+ * lifetime of one `refinePanorama` call:
1943
+ *
1944
+ * - "validating" (fraction 0.05) — synchronous input checks
1945
+ * - "stitching" (fraction 0.10) — start of the OpenCV stitch
1946
+ * - "writing" (fraction 0.90) — stitch returned, JPEG written
1947
+ * - "done" (fraction 1.00) — promise about to resolve
1948
+ * - "error" (fraction 1.00) — failure path (errorMessage
1949
+ * is non-null)
1950
+ *
1951
+ * Coarse on purpose: OpenCV's Stitcher doesn't expose stage-by-
1952
+ * stage callbacks, so the 0.10 → 0.90 jump is one opaque step.
1953
+ * JS uses `stage` for the UI label and `fraction` for the spinner.
1954
+ *
1955
+ * iOS sibling: IncrementalStitcher.swift::emitRefineProgress.
1956
+ * Field names + stage strings are kept identical so the JS
1957
+ * subscriber in src/stitching/incremental.ts doesn't branch on
1958
+ * platform.
1959
+ */
1960
+ private fun emitRefineProgress(
1961
+ stage: String,
1962
+ fraction: Double,
1963
+ frames: Int?,
1964
+ errorMessage: String?,
1965
+ ) {
1966
+ val state = Arguments.createMap().apply {
1967
+ putNull("panoramaPath")
1968
+ putInt("width", 0)
1969
+ putInt("height", 0)
1970
+ putInt("acceptedCount", 0)
1971
+ putInt("outcome", 0) // AcceptedHigh
1972
+ putDouble("confidence", 1.0)
1973
+ putDouble("overlapPercent", -1.0)
1974
+ putInt("processingMs", 0)
1975
+ putBoolean("isLandscape", false)
1976
+ putInt("paintedExtent", 0)
1977
+ putInt("panExtent", 0)
1978
+ putInt("keyframeMax", 0)
1979
+ putString("refineStage", stage)
1980
+ putDouble("refineProgress", fraction)
1981
+ if (frames != null) {
1982
+ putInt("refineFrames", frames)
1983
+ }
1984
+ if (errorMessage != null) {
1985
+ putString("refineError", errorMessage)
1986
+ }
1987
+ }
1988
+ emitState(state)
1989
+ }
1990
+
1886
1991
  /**
1887
1992
  * 2026-05-16 — given the live panorama path, derive a sibling
1888
1993
  * path for the refined output. Same algorithm iOS uses:
@@ -78,4 +78,14 @@ void StitcherWorkletRegistry::_resetForTests() {
78
78
  _nextId = 0;
79
79
  }
80
80
 
81
+ std::string StitcherWorkletRegistry::_installEntryForTests(
82
+ std::shared_ptr<RNWorklet::WorkletInvoker> invoker) {
83
+ std::lock_guard<std::mutex> lock(_mutex);
84
+ std::ostringstream idStream;
85
+ idStream << "host-" << _nextId++;
86
+ std::string id = idStream.str();
87
+ _entries.push_back({id, std::move(invoker)});
88
+ return id;
89
+ }
90
+
81
91
  } // namespace retailens
@@ -123,6 +123,16 @@ class StitcherWorkletRegistry {
123
123
  /// exposed through the JSI surface.
124
124
  void _resetForTests();
125
125
 
126
+ /// Test-only — install a pre-constructed entry directly, bypassing
127
+ /// the JSI runtime path. Mirrors `install` but accepts an already-
128
+ /// constructed (or null) `WorkletInvoker` so tests can exercise
129
+ /// `count`/`snapshot`/`uninstall`/thread-safety without standing
130
+ /// up a full JSI runtime + worklets-core stack. Tests typically
131
+ /// pass `nullptr` — the registry never dereferences the pointer.
132
+ /// Not exposed through the JSI surface.
133
+ std::string _installEntryForTests(
134
+ std::shared_ptr<RNWorklet::WorkletInvoker> invoker);
135
+
126
136
  private:
127
137
  StitcherWorkletRegistry() = default;
128
138
  StitcherWorkletRegistry(const StitcherWorkletRegistry&) = delete;
@@ -0,0 +1,98 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ #
3
+ # cpp/tests/CMakeLists.txt — v0.10.0 audit #9A
4
+ #
5
+ # Standalone Google Test runner for the shared C++ port under `cpp/`.
6
+ # Build + run via:
7
+ #
8
+ # cmake -S cpp/tests -B build/cpp-tests
9
+ # cmake --build build/cpp-tests
10
+ # (cd build/cpp-tests && ctest --output-on-failure)
11
+ #
12
+ # Or, from the repo root: `scripts/run-cpp-tests.sh`.
13
+ #
14
+ # What this DOES test (v0.10.0 scope):
15
+ # - Pure-C++ types in cpp/: `Pose`, `PlaneTransform`,
16
+ # `StitcherFrameData`, `PixelBufferReader` interface contract.
17
+ # - `StitcherWorkletRegistry` lifecycle (count, snapshot, uninstall,
18
+ # thread-safety) — compiled against JSI/worklets-core stubs under
19
+ # `cpp/tests/stubs/`. See `stubs/jsi/jsi.h` for the strategy.
20
+ #
21
+ # What this DOES NOT test yet (deferred to v0.11.0+):
22
+ # - `KeyframeGate` — depends on OpenCV. Will need an OpenCV-aware
23
+ # CMake config (link the same opencv_world the prod build uses).
24
+ # - JSI host-object dispatch — needs a real Hermes runtime.
25
+ # - Anything in `stitcher.cpp` (uses OpenCV stitching pipeline).
26
+
27
+ cmake_minimum_required(VERSION 3.20)
28
+ project(stitcher_cpp_tests CXX)
29
+
30
+ set(CMAKE_CXX_STANDARD 17)
31
+ set(CMAKE_CXX_STANDARD_REQUIRED ON)
32
+ set(CMAKE_CXX_EXTENSIONS OFF)
33
+
34
+ # Must precede `gtest_discover_tests` — without this the discovered
35
+ # cases aren't registered into a CTestTestfile.cmake at this directory
36
+ # level, and `ctest` reports "No tests were found".
37
+ enable_testing()
38
+
39
+ # Fetch GoogleTest pinned to v1.14.0. Pin matches what the AOSP /
40
+ # Android NDK test ecosystem uses today; bumps should be deliberate.
41
+ include(FetchContent)
42
+ FetchContent_Declare(
43
+ googletest
44
+ GIT_REPOSITORY https://github.com/google/googletest.git
45
+ GIT_TAG v1.14.0
46
+ )
47
+ # Prevent GoogleTest from overriding our compiler/linker options
48
+ # (Windows-only quirk; harmless on macOS/Linux).
49
+ set(gtest_force_shared_crt ON CACHE BOOL "" FORCE)
50
+ FetchContent_MakeAvailable(googletest)
51
+
52
+ # ─────────────────────────────────────────────────────────────────────
53
+ # Include paths
54
+ # ─────────────────────────────────────────────────────────────────────
55
+ # Order matters: stubs/ comes FIRST so `#include <jsi/jsi.h>` and
56
+ # `#include <react-native-worklets-core/WKTJsiWorklet.h>` resolve to
57
+ # the test-only stubs before any system header. The production cpp/
58
+ # directory comes after so retailens:: headers (e.g. ar_frame_pose.h,
59
+ # stitcher_frame_data.hpp) are found as the production code expects.
60
+ include_directories(
61
+ ${CMAKE_CURRENT_SOURCE_DIR}/stubs
62
+ ${CMAKE_CURRENT_SOURCE_DIR}/..
63
+ )
64
+
65
+ # ─────────────────────────────────────────────────────────────────────
66
+ # Test executable
67
+ # ─────────────────────────────────────────────────────────────────────
68
+ add_executable(stitcher_cpp_tests
69
+ # Production sources under test (only those whose deps we can satisfy
70
+ # without OpenCV / a real JSI runtime).
71
+ ${CMAKE_CURRENT_SOURCE_DIR}/../stitcher_worklet_registry.cpp
72
+
73
+ # Test sources.
74
+ ${CMAKE_CURRENT_SOURCE_DIR}/pose_test.cpp
75
+ ${CMAKE_CURRENT_SOURCE_DIR}/stitcher_frame_data_test.cpp
76
+ ${CMAKE_CURRENT_SOURCE_DIR}/stitcher_worklet_registry_test.cpp
77
+ )
78
+
79
+ target_link_libraries(stitcher_cpp_tests
80
+ PRIVATE
81
+ GTest::gtest_main
82
+ )
83
+
84
+ # Treat warnings as errors in the test build to catch the kind of
85
+ # silent regressions (unused variables, signed/unsigned comparisons,
86
+ # narrowing conversions) that production cross-compilation flags would
87
+ # otherwise suppress.
88
+ if(CMAKE_CXX_COMPILER_ID MATCHES "Clang|GNU")
89
+ target_compile_options(stitcher_cpp_tests PRIVATE
90
+ -Wall -Wextra -Wpedantic -Werror
91
+ )
92
+ endif()
93
+
94
+ # Register with CTest so `ctest --output-on-failure` picks up the
95
+ # individual TEST() cases (gtest_discover_tests scans the binary at
96
+ # build time, no manual ADD_TEST per case).
97
+ include(GoogleTest)
98
+ gtest_discover_tests(stitcher_cpp_tests)
@@ -0,0 +1,86 @@
1
+ # `cpp/tests/` — shared C++ unit test suite
2
+
3
+ v0.10.0 audit `#9A` introduced this directory to give the cross-platform
4
+ shared C++ code under `cpp/` a Google Test harness that runs on the
5
+ developer's host machine (not on a device or emulator). Pairs with the
6
+ Android-side JUnit suite added in `#11A` (see
7
+ `android/src/test/java/io/imagestitcher/rn/`).
8
+
9
+ ## Run
10
+
11
+ ```sh
12
+ scripts/run-cpp-tests.sh # configure + build + ctest
13
+ scripts/run-cpp-tests.sh --clean # nuke build/cpp-tests/ first
14
+ ```
15
+
16
+ Requires `cmake ≥ 3.20` and a C++17 toolchain (the macOS AppleClang
17
+ shipped with Xcode 14+ is fine; Linux GCC 9+ / Clang 10+ also work).
18
+
19
+ Build artefacts land under `build/cpp-tests/` (gitignored). Google
20
+ Test is fetched at configure time via CMake `FetchContent` pinned to
21
+ `v1.14.0`; no system-wide install required.
22
+
23
+ ## Scope (v0.10.0)
24
+
25
+ Covered:
26
+
27
+ - `Pose`, `PlaneTransform` (POD layout / size / field offsets — pinned
28
+ to the cross-platform marshalling contract documented in
29
+ `cpp/ar_frame_pose.h`).
30
+ - `StitcherFrameData` (default-construction invariants the JSI host
31
+ object's `get()` dispatch relies on).
32
+ - `PixelBufferReader` interface contract (clipping behaviour of
33
+ `copyTo` — validated via the `FakePixelBufferReader` test helper).
34
+ - `StitcherWorkletRegistry` storage lifecycle: shared-instance,
35
+ install/uninstall/count/snapshot, snapshot independence, concurrent
36
+ installs yield unique IDs (16 threads × 32 installs).
37
+
38
+ Not yet covered (intentional deferrals):
39
+
40
+ - `KeyframeGate` (`cpp/keyframe_gate.cpp`) — depends on OpenCV
41
+ (`opencv2/imgproc.hpp`, `opencv2/video.hpp` for `calcOpticalFlowPyrLK`).
42
+ Linking the production OpenCV xcframework / Android SDK into the
43
+ host-side test target would balloon CI time and disk usage; the
44
+ alternative is to land a stripped-down `libopencv-core` host build
45
+ just for tests. Deferred — comes with the v0.11.0 cross-platform
46
+ parity suite (`#2C`).
47
+ - `stitcher.cpp` — uses the full OpenCV stitching pipeline; same
48
+ reason as above.
49
+ - JSI host-object dispatch (`stitcher_frame_jsi.cpp`,
50
+ `stitcher_proxy_jsi.cpp`, `stitcher_worklet_dispatch.cpp`) — needs
51
+ a real Hermes runtime. The `StitcherWorkletRegistry` tests sidestep
52
+ this via the `_installEntryForTests` seam + JSI stubs under
53
+ `stubs/`; the JSI dispatch paths can't be similarly stubbed because
54
+ they actively call into the runtime.
55
+
56
+ ## How the JSI-dependent registry tests work without a real JSI
57
+
58
+ `stitcher_worklet_registry.cpp` `#include`s
59
+ `<jsi/jsi.h>` and `<react-native-worklets-core/WKTJsiWorklet.h>` to
60
+ construct `WorkletInvoker` instances from a real JS runtime. The test
61
+ target sidesteps both by:
62
+
63
+ 1. Putting `cpp/tests/stubs/` first on the compiler's include path so
64
+ `#include <jsi/jsi.h>` resolves to `stubs/jsi/jsi.h` (which declares
65
+ `facebook::jsi::Runtime` / `Value` as empty classes — enough for
66
+ the registry's reference-only usage), and
67
+ `#include <react-native-worklets-core/WKTJsiWorklet.h>` resolves to
68
+ `stubs/react-native-worklets-core/WKTJsiWorklet.h` (which declares
69
+ `RNWorklet::WorkletInvoker` with a no-op constructor).
70
+ 2. Calling `_installEntryForTests(nullptr)` instead of the production
71
+ `install(runtime, value)` path. The registry stores the
72
+ `shared_ptr<WorkletInvoker>` but never dereferences it (it only
73
+ hands it back via `snapshot`), so `nullptr` is safe.
74
+
75
+ The stubs live exclusively under `cpp/tests/stubs/`; production
76
+ builds never see them. See `stubs/jsi/jsi.h`'s docstring for the
77
+ guard-rails.
78
+
79
+ ## When NOT to add a test here
80
+
81
+ - If the test needs a real JSI runtime, real OpenCV operations, or
82
+ real-device sensor data, it belongs in `android/src/androidTest/`
83
+ (instrumented), the iOS Swift test target, or the v0.11.0 parity
84
+ harness — NOT here.
85
+ - If the test verifies TypeScript/JS-side behaviour, it belongs under
86
+ `src/**/__tests__/` (Jest).