react-native-image-stitcher 0.9.0 → 0.10.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 +150 -0
- package/android/build.gradle +10 -0
- package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +115 -10
- package/cpp/stitcher_worklet_registry.cpp +10 -0
- package/cpp/stitcher_worklet_registry.hpp +10 -0
- package/cpp/tests/CMakeLists.txt +98 -0
- package/cpp/tests/README.md +86 -0
- package/cpp/tests/pose_test.cpp +74 -0
- package/cpp/tests/stitcher_frame_data_test.cpp +132 -0
- package/cpp/tests/stitcher_worklet_registry_test.cpp +195 -0
- package/cpp/tests/stubs/jsi/jsi.h +33 -0
- package/cpp/tests/stubs/react-native-worklets-core/WKTJsiWorklet.h +34 -0
- package/dist/camera/useCapture.d.ts +1 -1
- package/dist/camera/useCapture.js +1 -1
- package/dist/stitching/incremental.d.ts +41 -0
- package/dist/stitching/useFrameStream.js +52 -37
- package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +138 -9
- package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.swift +50 -14
- package/package.json +1 -1
- package/src/camera/useCapture.ts +1 -1
- package/src/stitching/__tests__/subscribeIncrementalState.refine.test.ts +276 -0
- package/src/stitching/incremental.ts +42 -0
- package/src/stitching/useFrameStream.ts +55 -39
package/CHANGELOG.md
CHANGED
|
@@ -16,6 +16,132 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
16
16
|
|
|
17
17
|
## [Unreleased]
|
|
18
18
|
|
|
19
|
+
## [0.10.0] — 2026-05-28
|
|
20
|
+
|
|
21
|
+
### Added — v0.10.0 PR A: host-side test infrastructure (`#9A` + `#11A`)
|
|
22
|
+
|
|
23
|
+
Two parallel test harnesses landed so future tech-debt PRs in the
|
|
24
|
+
v0.10.0 sweep (and beyond) can pin invariants without standing up a
|
|
25
|
+
device build per change.
|
|
26
|
+
|
|
27
|
+
#### Shared C++ Google Test runner (`#9A`)
|
|
28
|
+
|
|
29
|
+
- `cpp/tests/CMakeLists.txt` — standalone CMake project that fetches
|
|
30
|
+
Google Test `v1.14.0` via `FetchContent` and compiles a single
|
|
31
|
+
`stitcher_cpp_tests` executable. No system-wide gtest install
|
|
32
|
+
required.
|
|
33
|
+
- `scripts/run-cpp-tests.sh` — one-shot configure / build / `ctest`
|
|
34
|
+
driver. Output lands under gitignored `build/cpp-tests/`.
|
|
35
|
+
- Initial suite (17 cases):
|
|
36
|
+
- `Pose` / `PlaneTransform` POD layout, size, field-offset
|
|
37
|
+
invariants (pinned to the cross-platform marshalling contract
|
|
38
|
+
in `cpp/ar_frame_pose.h`).
|
|
39
|
+
- `StitcherFrameData` default-construction invariants the JSI
|
|
40
|
+
host-object `get()` dispatch depends on (e.g. `qw=1.0`,
|
|
41
|
+
`hasTranslation=false`).
|
|
42
|
+
- `PixelBufferReader` `copyTo` clipping contract — validated via
|
|
43
|
+
a `FakePixelBufferReader` test helper.
|
|
44
|
+
- `StitcherWorkletRegistry` lifecycle: shared-instance, install
|
|
45
|
+
/uninstall/count/snapshot, snapshot independence from later
|
|
46
|
+
mutations, concurrent installs (16 threads × 32 each) yield
|
|
47
|
+
unique IDs without lock contention bugs.
|
|
48
|
+
- New test-only registry seam `_installEntryForTests(invoker)` (in
|
|
49
|
+
`cpp/stitcher_worklet_registry.{hpp,cpp}`) — mirrors the existing
|
|
50
|
+
`_resetForTests` pattern. Bypasses the JSI runtime path so tests
|
|
51
|
+
don't need Hermes + worklets-core; `nullptr` invokers are safe
|
|
52
|
+
because the registry never dereferences them.
|
|
53
|
+
- JSI / worklets-core stubs under `cpp/tests/stubs/` let
|
|
54
|
+
`stitcher_worklet_registry.cpp` compile in the host-side test
|
|
55
|
+
target without pulling in React Native's JSI headers or the
|
|
56
|
+
worklets-core library. Stubs are scoped exclusively to the test
|
|
57
|
+
include path; production builds never see them.
|
|
58
|
+
- See `cpp/tests/README.md` for the strategy + a list of what's
|
|
59
|
+
deferred to v0.11.0+ (KeyframeGate / OpenCV-dependent code; JSI
|
|
60
|
+
host-object dispatch).
|
|
61
|
+
|
|
62
|
+
#### Android JUnit scaffold (`#11A`)
|
|
63
|
+
|
|
64
|
+
- `android/build.gradle` — adds `testImplementation
|
|
65
|
+
"junit:junit:4.13.2"`. Minimal — only JUnit 4 (matches AGP's
|
|
66
|
+
default test runner).
|
|
67
|
+
- `android/src/test/java/io/imagestitcher/rn/TransferredNV21Test.kt`
|
|
68
|
+
— 6 tests covering the v0.10.0 `TransferredNV21` single-use
|
|
69
|
+
ownership wrapper: constructor empty/non-empty, takeOnce returns
|
|
70
|
+
the original reference, takeOnce throws on second call, thread-
|
|
71
|
+
safe single-winner under 16-thread contention, distinct wrappers
|
|
72
|
+
are independent.
|
|
73
|
+
- Run via `./gradlew :react-native-image-stitcher:testDebugUnitTest`.
|
|
74
|
+
|
|
75
|
+
Neither suite changes runtime behaviour — both are additive test
|
|
76
|
+
infrastructure.
|
|
77
|
+
|
|
78
|
+
### Added — v0.10.0 PR B: `refinePanorama` progress events + cleanup audit (`#15A` + `#16C`)
|
|
79
|
+
|
|
80
|
+
#### `#15A` — phase-milestone progress emit from `refinePanorama`
|
|
81
|
+
|
|
82
|
+
`refinePanorama` (both the explicit JS `module.refinePanorama(...)` API
|
|
83
|
+
and the hybrid-engine auto-refine path that calls it internally) now
|
|
84
|
+
emits coarse phase events on the existing `IncrementalStateUpdate`
|
|
85
|
+
device-event channel. Five stages cover one refine lifetime:
|
|
86
|
+
|
|
87
|
+
| Stage | `refineProgress` | When |
|
|
88
|
+
| ------------- | ---------------- | ------------------------------------ |
|
|
89
|
+
| `validating` | 0.05 | start of method, before any I/O |
|
|
90
|
+
| `stitching` | 0.10 | OpenCV stitch in flight |
|
|
91
|
+
| `writing` | 0.90 | stitch returned, JPEG written |
|
|
92
|
+
| `done` | 1.00 | success — promise about to resolve |
|
|
93
|
+
| `error` | 1.00 | failure — `refineError` is set |
|
|
94
|
+
|
|
95
|
+
`refineStage` carries the stage string; `refineProgress` carries the
|
|
96
|
+
fraction; `refineFrames` reports the input keyframe count; `refineError`
|
|
97
|
+
is populated on the failure path so the host can render a one-line
|
|
98
|
+
failure pill.
|
|
99
|
+
|
|
100
|
+
Coarse on purpose: OpenCV's `Stitcher` doesn't expose mid-pipeline
|
|
101
|
+
progress, so the `0.10 → 0.90` jump is one opaque step. JS uses
|
|
102
|
+
`refineStage` for the UI label and `refineProgress` purely for the
|
|
103
|
+
spinner.
|
|
104
|
+
|
|
105
|
+
Reuses the existing channel (no second listener wiring required).
|
|
106
|
+
Existing JS consumers that don't read the new fields are unaffected.
|
|
107
|
+
|
|
108
|
+
#### `#16C` — moderate cleanup audit sweep
|
|
109
|
+
|
|
110
|
+
- `src/camera/useCapture.ts` — removed a stale "`useVideoCapture` (TODO)"
|
|
111
|
+
reference; the hook has existed since v0.4.
|
|
112
|
+
- `ios/Sources/RNImageStitcher/IncrementalStitcherBridge.swift` — removed
|
|
113
|
+
a self-flagged "remove this comment after" reference left over from a
|
|
114
|
+
past PiP investigation.
|
|
115
|
+
- `console.*` audit: every call in `src/` was reviewed; all 13 are
|
|
116
|
+
legitimate (warn/error for surfaceable failures; `console.info`
|
|
117
|
+
one-shots that document known tradeoffs). No removals needed.
|
|
118
|
+
- TODO/FIXME triage: 4 remaining own-code TODOs all reference tracked
|
|
119
|
+
future work (lens-probe follow-up, shared-stitcher-port-part-2,
|
|
120
|
+
EXIF writer). Left in place.
|
|
121
|
+
- `ts-prune`: 3 surface-level orphans (`PanoramaConfirmModal`,
|
|
122
|
+
`IncrementalStitcherView`, `stitchFrames`) are intentional public
|
|
123
|
+
deep-import API; not re-exported from `src/index.ts` but
|
|
124
|
+
documented and consumed by hosts. Left in place.
|
|
125
|
+
|
|
126
|
+
No production behaviour changed — these are docstring + dead-comment
|
|
127
|
+
removals only.
|
|
128
|
+
|
|
129
|
+
### Fixed — v0.10.0 PR B (iOS): refine state events not reaching JS under RN bridgeless interop
|
|
130
|
+
|
|
131
|
+
Switched `IncrementalStitcherBridge` state-event delivery from
|
|
132
|
+
`RCTEventEmitter.sendEvent` to `bridge.enqueueJSCall("RCTDeviceEventEmitter", "emit", ...)`.
|
|
133
|
+
Root cause: under RN bridgeless interop (RN 0.84), `sendEvent`
|
|
134
|
+
silently no-ops for some event-body shapes even when the bridge is
|
|
135
|
+
non-nil and the listener count is > 0 — refine events with the
|
|
136
|
+
`refineStage` / `refineProgress` / `refineFrames` keys were not
|
|
137
|
+
reaching any JS subscriber while live state events with a smaller
|
|
138
|
+
body shape on the same channel were. Also defensively
|
|
139
|
+
`removeObserver` before `addObserver` in `init()` so the
|
|
140
|
+
NotificationCenter registration is idempotent if RN re-invokes
|
|
141
|
+
`init()` on the same instance (also observed on bridgeless interop).
|
|
142
|
+
Android is unaffected — Android's bridge already emits via
|
|
143
|
+
`DeviceEventManagerModule.RCTDeviceEventEmitter.emit(...)` directly.
|
|
144
|
+
|
|
19
145
|
## [0.9.0] — 2026-05-27
|
|
20
146
|
|
|
21
147
|
### Added — layered frame-access helpers
|
|
@@ -125,6 +251,30 @@ the Layer 1 + 2 + 3 pipeline working end-to-end on both iPhone
|
|
|
125
251
|
- New hooks return `useFrameProcessor`-shape objects compatible
|
|
126
252
|
with `<Camera frameProcessor={...}>` (Phase 5 from v0.8.0).
|
|
127
253
|
|
|
254
|
+
### Known limitations (v0.9.0 — addressed in v0.11.0)
|
|
255
|
+
|
|
256
|
+
- **Layer 3 `useFrameStream` in AR mode**: the Layer 1
|
|
257
|
+
`save_frame_as_jpeg` vc Frame Processor plugin expects a vision-
|
|
258
|
+
camera `Frame` with `.buffer = CMSampleBufferRef`. In AR mode
|
|
259
|
+
the worklet receives a `StitcherFrameHostObject` (v0.8.0
|
|
260
|
+
Phase 4b's JSI host object) without `.buffer` — the plugin call
|
|
261
|
+
returns `{ok: false, ...}` and `useFrameStream` silently skips
|
|
262
|
+
the sample. Hosts needing per-frame native processing in AR
|
|
263
|
+
mode should use **Layer 2 (`useThrottledFrameProcessor`)** —
|
|
264
|
+
it works in both modes and is the right primitive for the
|
|
265
|
+
worklet-native use cases listed in `docs/frame-access-tiers.md`
|
|
266
|
+
(OCR via Vision/ML Kit, TFLite ML detection, LiDAR depth).
|
|
267
|
+
AR-mode Layer 3 support tracked in v0.11.0's plan
|
|
268
|
+
(`docs/plans/2026-05-27-v0.11.0-non-ar-composition.md`) —
|
|
269
|
+
bundled with `useStitcherWorklet` since both extend the
|
|
270
|
+
`__stitcherProxy` host-function infrastructure.
|
|
271
|
+
- **Layer 3 `useFrameStream` in non-AR mode**: wiring the host's
|
|
272
|
+
frameProcessor through `<Camera>` displaces the lib's
|
|
273
|
+
first-party stitching driver (the documented Phase 5 either-or
|
|
274
|
+
constraint from v0.8.0). Non-AR panorama capture won't produce
|
|
275
|
+
stitched output while a host frameProcessor is wired. Tracked
|
|
276
|
+
in v0.11.0 (`useStitcherWorklet` composition).
|
|
277
|
+
|
|
128
278
|
### Notes
|
|
129
279
|
|
|
130
280
|
- Formal SSIM parity gate (Phase 7 of the v0.9.0 plan) was NOT
|
package/android/build.gradle
CHANGED
|
@@ -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
|
-
|
|
1678
|
-
"
|
|
1679
|
-
|
|
1680
|
-
|
|
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
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
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
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
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).
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
//
|
|
3
|
+
// pose_test.cpp — v0.10.0 audit #9A
|
|
4
|
+
//
|
|
5
|
+
// Layout / size invariants for the cross-platform POD structs that
|
|
6
|
+
// marshal AR-frame pose data between Swift/Kotlin and shared C++.
|
|
7
|
+
// The Pose / PlaneTransform structs MUST stay binary-compatible
|
|
8
|
+
// across iOS (Swift → C++) and Android (Kotlin → JNI → C++) — any
|
|
9
|
+
// silent field reorder, padding shift, or size change would diverge
|
|
10
|
+
// gate decisions between platforms.
|
|
11
|
+
//
|
|
12
|
+
// These are pinned to the contract in `cpp/ar_frame_pose.h`'s
|
|
13
|
+
// docstring; if the struct shape evolves intentionally, update both
|
|
14
|
+
// the docstring and these tests in the same commit.
|
|
15
|
+
|
|
16
|
+
#include "ar_frame_pose.h"
|
|
17
|
+
|
|
18
|
+
#include <gtest/gtest.h>
|
|
19
|
+
|
|
20
|
+
#include <cstddef>
|
|
21
|
+
#include <type_traits>
|
|
22
|
+
|
|
23
|
+
using retailens::Pose;
|
|
24
|
+
using retailens::PlaneTransform;
|
|
25
|
+
|
|
26
|
+
TEST(PoseLayoutTest, IsStandardLayoutPod) {
|
|
27
|
+
// Required for `memcpy` marshalling and for the iOS Obj-C++ /
|
|
28
|
+
// Android JNI bridges to write into the struct directly.
|
|
29
|
+
EXPECT_TRUE(std::is_standard_layout<Pose>::value);
|
|
30
|
+
EXPECT_TRUE(std::is_trivially_copyable<Pose>::value);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
TEST(PoseLayoutTest, SizeMatchesExpectedFields) {
|
|
34
|
+
// 11 floats (tx, ty, tz, qx, qy, qz, qw, fx, fy, cx, cy) + 2 int32_t
|
|
35
|
+
// (imageWidth, imageHeight) = 11*4 + 2*4 = 52 bytes. No padding
|
|
36
|
+
// expected: every field is 4-byte aligned and the struct contains
|
|
37
|
+
// only 4-byte primitives.
|
|
38
|
+
EXPECT_EQ(sizeof(Pose), static_cast<std::size_t>(11 * 4 + 2 * 4));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
TEST(PoseLayoutTest, FieldOrderMatchesContract) {
|
|
42
|
+
// Translation comes before rotation; rotation before intrinsics;
|
|
43
|
+
// intrinsics before image dimensions. Swift / Kotlin marshallers
|
|
44
|
+
// assume this order — flipping any pair silently breaks the
|
|
45
|
+
// memcpy-based bridge.
|
|
46
|
+
EXPECT_EQ(offsetof(Pose, tx), 0u);
|
|
47
|
+
EXPECT_EQ(offsetof(Pose, ty), sizeof(float) * 1);
|
|
48
|
+
EXPECT_EQ(offsetof(Pose, tz), sizeof(float) * 2);
|
|
49
|
+
EXPECT_EQ(offsetof(Pose, qx), sizeof(float) * 3);
|
|
50
|
+
EXPECT_EQ(offsetof(Pose, qy), sizeof(float) * 4);
|
|
51
|
+
EXPECT_EQ(offsetof(Pose, qz), sizeof(float) * 5);
|
|
52
|
+
EXPECT_EQ(offsetof(Pose, qw), sizeof(float) * 6);
|
|
53
|
+
EXPECT_EQ(offsetof(Pose, fx), sizeof(float) * 7);
|
|
54
|
+
EXPECT_EQ(offsetof(Pose, fy), sizeof(float) * 8);
|
|
55
|
+
EXPECT_EQ(offsetof(Pose, cx), sizeof(float) * 9);
|
|
56
|
+
EXPECT_EQ(offsetof(Pose, cy), sizeof(float) * 10);
|
|
57
|
+
EXPECT_EQ(offsetof(Pose, imageWidth), sizeof(float) * 11);
|
|
58
|
+
EXPECT_EQ(offsetof(Pose, imageHeight),
|
|
59
|
+
sizeof(float) * 11 + sizeof(int32_t));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
TEST(PlaneTransformLayoutTest, IsStandardLayoutPod) {
|
|
63
|
+
EXPECT_TRUE(std::is_standard_layout<PlaneTransform>::value);
|
|
64
|
+
EXPECT_TRUE(std::is_trivially_copyable<PlaneTransform>::value);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
TEST(PlaneTransformLayoutTest, SixteenFloatsContiguous) {
|
|
68
|
+
// The `m[16]` array MUST be a contiguous 64-byte block — both
|
|
69
|
+
// bridges call `memcpy(planeTransform.m, source, 64)`. No leading
|
|
70
|
+
// padding, no field reorder (there's only one field, but pinning the
|
|
71
|
+
// size catches any accidental wrapper/struct change).
|
|
72
|
+
EXPECT_EQ(sizeof(PlaneTransform), static_cast<std::size_t>(16 * 4));
|
|
73
|
+
EXPECT_EQ(offsetof(PlaneTransform, m), 0u);
|
|
74
|
+
}
|