react-native-image-stitcher 0.16.0 → 0.16.2

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 (38) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.md +41 -44
  3. package/android/build.gradle +34 -0
  4. package/android/src/main/cpp/image_stitcher_jni.cpp +52 -7
  5. package/android/src/main/cpp/keyframe_gate_jni.cpp +15 -8
  6. package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +135 -59
  7. package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +7 -36
  8. package/cpp/keyframe_gate.cpp +54 -15
  9. package/cpp/keyframe_gate.hpp +33 -0
  10. package/cpp/stitcher.cpp +481 -87
  11. package/cpp/stitcher.hpp +52 -0
  12. package/dist/camera/Camera.d.ts +13 -0
  13. package/dist/camera/Camera.js +9 -64
  14. package/dist/camera/CaptureFrameCounterOverlay.js +12 -1
  15. package/dist/camera/CaptureMemoryPill.d.ts +15 -7
  16. package/dist/camera/CaptureMemoryPill.js +34 -9
  17. package/dist/camera/PanoramaBandOverlay.d.ts +2 -1
  18. package/dist/camera/PanoramaBandOverlay.js +9 -3
  19. package/dist/camera/PanoramaSettings.js +22 -25
  20. package/dist/camera/RectCropPreview.d.ts +3 -29
  21. package/dist/camera/RectCropPreview.js +20 -130
  22. package/dist/stitching/incremental.d.ts +29 -0
  23. package/dist/stitching/useIncrementalStitcher.d.ts +7 -1
  24. package/dist/stitching/useIncrementalStitcher.js +7 -1
  25. package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +103 -26
  26. package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.m +4 -0
  27. package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.swift +15 -0
  28. package/ios/Sources/RNImageStitcher/OpenCVStitcher.mm +21 -1
  29. package/package.json +1 -1
  30. package/src/camera/Camera.tsx +21 -70
  31. package/src/camera/CaptureFrameCounterOverlay.tsx +15 -1
  32. package/src/camera/CaptureMemoryPill.tsx +33 -9
  33. package/src/camera/PanoramaBandOverlay.tsx +9 -1
  34. package/src/camera/PanoramaSettings.ts +22 -25
  35. package/src/camera/RectCropPreview.tsx +38 -220
  36. package/src/stitching/incremental.ts +29 -0
  37. package/src/stitching/useIncrementalStitcher.ts +13 -0
  38. package/android/src/main/java/io/imagestitcher/rn/TransferredNV21.kt +0 -100
package/CHANGELOG.md CHANGED
@@ -14,6 +14,86 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
14
14
  > during 0.x are bumped to a new MINOR (e.g., 0.1 → 0.2), and the
15
15
  > upgrade path is documented in this CHANGELOG.
16
16
 
17
+ ## [0.16.2] — 2026-06-17
18
+
19
+ ### Added — reuse the bundled OpenCV from your host app's native code (Android)
20
+
21
+ A host app's own native (C++/NDK) code can now reuse the **same** custom
22
+ OpenCV this library bundles (4.10.0, arm64-v8a) — **including `cv::Stitcher`**
23
+ — with no second copy of `libopencv_java4.so` in the APK.
24
+
25
+ The Android build now publishes the location of its vendored OpenCV SDK via
26
+ `rootProject.ext.rnisOpenCVDir` (and `rnisOpenCVAndroidSdkDir`). A consumer
27
+ points its `externalNativeBuild` at `-DOpenCV_DIR=${rootProject.ext.rnisOpenCVDir}`,
28
+ calls `find_package(OpenCV)`, and links the shared `opencv_java` (core /
29
+ imgproc / calib3d / … resolved at runtime from the already-shipped `.so`)
30
+ plus the whole-archived static `opencv_stitching` (`cv::Stitcher`). A
31
+ build-verified consumer ships in the example app
32
+ (`example/android/app/src/main/cpp/`).
33
+
34
+ This is additive — no public API or runtime-behaviour change. AGP
35
+ `prefabPublishing` was evaluated and is unworkable for prebuilt OpenCV
36
+ (prefab only exports libraries the module itself builds), so OpenCV's own
37
+ first-class CMake package is used instead. iOS reuse (the vendored
38
+ `opencv2.xcframework`) is unchanged.
39
+
40
+ ### Docs
41
+
42
+ Documentation site refreshed: an easier **Getting started**, a complete
43
+ **`<Camera>` API** reference (every prop, the v0.16 guidance params —
44
+ `rectCrop` / `showPreview` / `panMode` / `panGuidance` / `maxPanDurationMs` /
45
+ `panTooFastThreshold` / `lateralBudgetCm` / `guidanceCopy` — and the
46
+ `stitcher` / `frameSelection` settings-JSON tables), a fully-loaded
47
+ **Complete example**, the v0.16 **Capture result & errors** union, and new
48
+ **Sharing OpenCV** / **Bring your own OpenCV** guides.
49
+
50
+ ## [0.16.1] — 2026-06-16
51
+
52
+ ### Changed — high-level `cv::Stitcher` is now the default pipeline
53
+
54
+ The batch finalize now drives OpenCV's high-level `cv::Stitcher`
55
+ (PANORAMA) on both platforms instead of the hand-rolled `cv::detail`
56
+ ("manual") path. In testing it produced consistently better seams and
57
+ lower, more stable peak memory. This is a **behaviour change, not an
58
+ API change** — the public surface (`<Camera>`, the hooks, the finalize
59
+ options) is unchanged; only the stitched output and memory profile
60
+ differ.
61
+
62
+ The warper is chosen per-capture (pure function of the selected lens +
63
+ pan direction), always `PANORAMA`:
64
+
65
+ | Lens | Mode A (vertical pan) | Mode B (horizontal pan) |
66
+ | ----- | --------------------- | ----------------------- |
67
+ | 1× | plane | cylindrical |
68
+ | 0.5× | spherical | spherical |
69
+
70
+ The lens comes from the explicit `1x` / `0.5x` the user selected
71
+ (plumbed through the finalize options); the previous FOV-from-intrinsics
72
+ heuristic was unreliable on multi-camera devices and is gone, along with
73
+ the now-redundant rotation-vs-translation (ex-SCANS) branch.
74
+
75
+ ### Added — production memory hardening on the high-level path
76
+
77
+ The OOM guards that previously only covered the manual path were ported
78
+ across, so the new default is memory-safe under pressure:
79
+
80
+ - pre-stitch RSS headroom abort (also works on iOS now via the
81
+ `phys_footprint` probe, which revives the runtime-pressure router);
82
+ - RAM-aware compositing resolution;
83
+ - two-phase `estimateTransform` → project the warp canvas → abort if
84
+ degenerate, downscale or route to the bounded spherical warper if
85
+ over budget;
86
+ - a full C++ catch ladder + a JNI backstop so an allocation failure can
87
+ no longer cross the C-ABI and abort the process;
88
+ - a warper→spherical rescue (high-level) with the manual `PANORAMA` ↔
89
+ `SCANS` mode-fallback preserved for the iOS manual callers.
90
+
91
+ ### Fixed
92
+
93
+ - The native allocator is purged after each stitch, and on Android the
94
+ OpenCV worker pool is pinned to one thread, eliminating the per-stitch
95
+ RSS creep observed on the manual path.
96
+
17
97
  ## [0.16.0] — 2026-06-15
18
98
 
19
99
  ### Added — first-time-user panorama capture GUIDANCE
package/README.md CHANGED
@@ -29,8 +29,8 @@ Peer dependencies (the host app provides these):
29
29
  "react": ">=18.0.0",
30
30
  "react-native": ">=0.72.0",
31
31
  "react-native-vision-camera": ">=4.7.0",
32
+ "react-native-worklets-core": ">=1.3.0",
32
33
  "react-native-sensors": ">=7.0.0",
33
- "expo-sensors": ">=14.0.0",
34
34
  "react-native-safe-area-context": ">=4.0.0"
35
35
  }
36
36
  ```
@@ -71,50 +71,32 @@ cd android && ./gradlew :app:assembleDebug # Android
71
71
  > See [Orientation support](#orientation-support) for the full story
72
72
  > (landscape *is* supported on iOS if you need it).
73
73
 
74
- The minimum: resolve camera permission, then mount `<Camera>` with an
75
- `onCapture` handler.
74
+ The minimum: mount `<Camera>` with an `onCapture` handler. It fires once
75
+ per capture attempt — gate on `result.ok` before reading the output.
76
76
 
77
77
  ```tsx
78
- import {
79
- Camera,
80
- type CameraCaptureResult,
81
- type CameraError,
82
- } from 'react-native-image-stitcher';
78
+ import { Camera, type CameraCaptureResult } from 'react-native-image-stitcher';
83
79
 
84
80
  export function CaptureScreen() {
85
- const handleCapture = (result: CameraCaptureResult) => {
86
- // `onCapture` fires on success AND failure — gate on `ok` first.
87
- if (!result.ok) {
88
- console.warn('capture failed:', result.error.code, result.error.message);
89
- return;
90
- }
91
- // Non-fatal quality signals (e.g. <70% of frames used). Always present.
92
- if (result.warnings.length > 0) {
93
- console.warn('warnings:', result.warnings.map((w) => w.code));
94
- }
95
- if (result.type === 'photo') {
96
- console.log('Photo:', result.uri, result.width, result.height);
97
- } else {
98
- console.log(
99
- 'Panorama:',
100
- result.uri,
101
- `${result.framesIncluded}/${result.framesRequested} frames`,
102
- `stitched as ${result.stitchModeResolved ?? 'n/a'}`,
103
- );
104
- }
105
- };
106
-
107
81
  return (
108
82
  <Camera
109
- onCapture={handleCapture}
110
- // onError still fires on failure too (an unchanged mirror of the
111
- // ok:false result above).
112
- onError={(err: CameraError) => console.warn(err.code, err.message)}
83
+ onCapture={(result: CameraCaptureResult) => {
84
+ if (!result.ok) {
85
+ console.warn('capture failed:', result.error.code);
86
+ return;
87
+ }
88
+ // result.type is 'photo' or 'panorama'; both carry uri/width/height.
89
+ console.log(result.type, result.uri, result.width, result.height);
90
+ }}
113
91
  />
114
92
  );
115
93
  }
116
94
  ```
117
95
 
96
+ > **Camera permission is the host's job.** The SDK never requests it for
97
+ > you — resolve it (e.g. with vision-camera's `useCameraPermission`)
98
+ > before mounting `<Camera>`.
99
+
118
100
  ### A complete capture screen
119
101
 
120
102
  A realistic screen: requests permission up front, shows a capture
@@ -353,21 +335,32 @@ omitted keys fall back to the English default. This covers the rotate prompt,
353
335
  the pan hint, the live "too fast" cue, the lateral-drift popups, the crop-editor
354
336
  buttons, the **capture-status banner**, and the **crop-editor warning banners**:
355
337
 
356
- | Key | Group | English default |
338
+ Each default is the **exact, complete** source string — translate it verbatim
339
+ (keep the `{included}` / `{requested}` / `{percent}` placeholders in
340
+ `warnLowFrameUtilization`), or `import { DEFAULT_GUIDANCE_COPY }` to seed your
341
+ catalogue programmatically.
342
+
343
+ | Key | Where it appears | English default (translate verbatim) |
357
344
  | --- | --- | --- |
358
345
  | `rotateToLandscape` | rotate prompt | `Rotate to landscape` |
359
346
  | `rotateToPortrait` | rotate prompt | `Rotate to portrait` |
360
- | `panHint` | pan how-to | `Pan slowly top to bottom` |
361
- | `tooFast` | speed cue | `Moving too fast — slow down` |
362
- | `lateralStopTitle` / `lateralStopBody` / `lateralStopDismiss` | lateral popup (stitched) | `Keep the pan straight` / … / `Got it` |
363
- | `lateralWrongDirectionTitle` / `lateralWrongDirectionBody` | lateral popup (too few frames) | `Follow the arrow` / |
364
- | `cropConfirm` / `cropReset` / `cropUseOriginal` / `cropRetake` | crop buttons | `Crop` / `Reset` / `Use original` / `Retake` |
365
- | `previewConfirm` | preview-only accept button (`showPreview`) | `Confirm` |
347
+ | `panHint` | pan how-to overlay | `Pan slowly top to bottom` |
348
+ | `tooFast` | speed-cue pill | `Moving too fast — slow down` |
349
+ | `lateralStopTitle` | lateral-drift popup (stitched) | `Keep the pan straight` |
350
+ | `lateralStopBody` | lateral-drift popup (stitched) | `You moved sideways. Pan in one direction only — we stitched what you captured.` |
351
+ | `lateralStopDismiss` | lateral-drift popup button | `Got it` |
352
+ | `lateralWrongDirectionTitle` | lateral-drift popup (too few frames) | `Follow the arrow` |
353
+ | `lateralWrongDirectionBody` | lateral-drift popup (too few frames) | `You moved the phone the wrong way. Pan slowly in the direction the arrow shows, in one straight line.` |
354
+ | `cropConfirm` | crop-editor button | `Crop` |
355
+ | `cropReset` | crop-editor button | `Reset` |
356
+ | `cropUseOriginal` | crop-editor button | `Use original` |
357
+ | `cropRetake` | crop-editor button | `Retake` |
358
+ | `previewConfirm` | preview accept button (`showPreview`) | `Confirm` |
366
359
  | `statusRecording` | status banner | `Hold steady — pan slowly` |
367
360
  | `statusStitching` | status banner | `Stitching panorama…` |
368
- | `warnLowFrameUtilization` | crop warning **(template)** | `Only {included} of {requested} captured frames ({percent}%) could be used — …` |
369
- | `warnLateralDriftFinalize` | crop warning | `Capture stopped early because the phone drifted sideways — …` |
370
- | `warnHighPanSpeed` | crop warning | `The capture was taken faster than the recommended pace — …` |
361
+ | `warnLowFrameUtilization` | crop warning **(template)** | `Only {included} of {requested} captured frames ({percent}%) could be used — the panorama may be incomplete. Pan more slowly and steadily next time.` |
362
+ | `warnLateralDriftFinalize` | crop warning | `Capture stopped early because the phone drifted sideways — only the part captured before the drift was stitched.` |
363
+ | `warnHighPanSpeed` | crop warning | `The capture was taken faster than the recommended pace — the result may not be the best. Pan more slowly next time.` |
371
364
 
372
365
  > **Templates:** `warnLowFrameUtilization` is interpolated at runtime — your
373
366
  > translation must keep the `{included}`, `{requested}` and `{percent}`
@@ -433,6 +426,10 @@ function CaptureScreen() {
433
426
  seed your translation catalogue from the source strings, and
434
427
  `RECOVERABLE_STITCH_GUIDANCE` exposes the built-in error copy for the same reason.
435
428
 
429
+ > **Full worked example** — a Spanish `es.json` catalogue (both surfaces) plus a
430
+ > host language-setting that switches the copy at runtime: see the
431
+ > [Internationalization guide](https://bhargavkanda.github.io/react-native-image-stitcher/docs/i18n#worked-example-spanish-with-a-dynamic-language-setting).
432
+
436
433
  ### Migration from 0.13.x
437
434
 
438
435
  - **Removed:** the `panGuide` and `panoramaGuidance` props (the
@@ -112,6 +112,40 @@ android {
112
112
  prefab true
113
113
  }
114
114
 
115
+ // ── Host OpenCV reuse: why NOT prefab publishing ─────────────────
116
+ //
117
+ // Goal: let a HOST app's own native (C++/NDK) code reuse the SAME
118
+ // custom OpenCV (4.10.0, arm64-v8a) this AAR already bundles — both
119
+ // libopencv_java4.so (cv::Mat, imgproc, features2d, calib3d, flann,
120
+ // photo, video …) AND cv::Stitcher (which lives in the static
121
+ // archive libopencv_stitching.a, NOT in the fat .so).
122
+ //
123
+ // We evaluated AGP `prefabPublishing` first (the idiomatic AAR way)
124
+ // and it CANNOT carry this OpenCV. Empirically verified: AGP's
125
+ // prefab modules only export libraries the module's own
126
+ // externalNativeBuild PRODUCES (here: just `image_stitcher`). A
127
+ // prebuilt jniLib (.so) or a prebuilt static archive (.a) is not an
128
+ // accepted prefab `libraryName` — the configure fails with
129
+ // `[CXX1404] did not find implicitly required targets`. So neither
130
+ // libopencv_java4.so nor libopencv_stitching.a can ride a prefab.
131
+ //
132
+ // Instead we expose the bundled OpenCV's OWN first-class CMake
133
+ // package — which already defines every module (incl. opencv_stitching
134
+ // as a STATIC IMPORTED target and opencv_java as a SHARED IMPORTED
135
+ // target) — to consumers via a rootProject ext property. A host
136
+ // points its externalNativeBuild at `-DOpenCV_DIR=<that dir>`, does
137
+ // `find_package(OpenCV REQUIRED)`, then links the SHARED `opencv_java`
138
+ // (cv::Mat & friends resolved at runtime from the AAR's already-shipped
139
+ // libopencv_java4.so — NO second copy) plus the whole-archived STATIC
140
+ // `opencv_stitching` (cv::Stitcher, a small private copy since it isn't
141
+ // in the fat .so). This is the idiomatic Android OpenCV-consumption
142
+ // path. See example/android/app for the working consumer.
143
+ //
144
+ // Set unconditionally at configure time so it's readable from the
145
+ // host app module regardless of project evaluation order.
146
+ rootProject.ext.rnisOpenCVAndroidSdkDir = "$opencvSdkDir/native"
147
+ rootProject.ext.rnisOpenCVDir = "$opencvSdkDir/native/jni"
148
+
115
149
  // ── JNI shim build path ─────────────────────────────────────────
116
150
  // Gradle compiles cpp/image_stitcher_jni.cpp into
117
151
  // libimage_stitcher.so for the ABIs filtered above. The shim
@@ -97,7 +97,11 @@ double procRssMB() {
97
97
  * static_cast<double>(sysconf(_SC_PAGE_SIZE)) / (1024.0 * 1024.0);
98
98
  }
99
99
 
100
- void purgeNativeAllocator() {
100
+ // Returns the POST-purge RSS in MB (the leak-plateau "floor"), or -1 when the
101
+ // diagnostic reads are gated off. The mallopt(M_PURGE) CALL is UNCONDITIONAL —
102
+ // it's the leak fix; only its before/after READS + the log are gated (3A), so a
103
+ // release build pays nothing while debug builds surface memFloor.
104
+ double purgeNativeAllocator(bool profiling) {
101
105
  using MalloptFn = int (*)(int, int);
102
106
  // Resolve mallopt at runtime (API-26 symbol; minSdk 24). Prefer an explicit
103
107
  // libc.so handle — RTLD_DEFAULT from a dlopen'd .so doesn't always reach
@@ -108,8 +112,9 @@ void purgeNativeAllocator() {
108
112
  if (s == nullptr) s = dlsym(RTLD_DEFAULT, "mallopt");
109
113
  return reinterpret_cast<MalloptFn>(s);
110
114
  }();
111
- const double before = procRssMB();
112
- if (fn != nullptr) fn(M_PURGE, 0);
115
+ const double before = profiling ? procRssMB() : -1.0;
116
+ if (fn != nullptr) fn(M_PURGE, 0); // the fix — always runs
117
+ if (!profiling) return -1.0;
113
118
  const double after = procRssMB();
114
119
  // Diagnostic: shows whether mallopt resolved and how much RSS the purge
115
120
  // actually returned to the OS. If mallopt=MISSING → dlsym failed; if
@@ -117,6 +122,7 @@ void purgeNativeAllocator() {
117
122
  // leak) and M_PURGE can't help.
118
123
  LOGI("[memstat] purge: mallopt=%s rss %.1f -> %.1f MB",
119
124
  (fn != nullptr) ? "ok" : "MISSING", before, after);
125
+ return after; // memFloor — the post-purge plateau metric
120
126
  }
121
127
 
122
128
  } // namespace
@@ -189,6 +195,9 @@ Java_io_imagestitcher_rn_BatchStitcher_nativeStitchFramePaths(
189
195
  // re-arms the manual pipeline's dynamic plane→spherical fallback/divergence
190
196
  // switch (they only fire when warperType != "spherical").
191
197
  cfg.useManualPipeline = (useManualPipeline == JNI_TRUE);
198
+ // 2026-06-16 — memory profiling (DEV). Gated by the compile flag (debug-on,
199
+ // release-off); Android leaves memProbeFn null so rss_mb() uses /proc.
200
+ cfg.enableMemoryProfiling = (RNIS_MEMORY_PROFILING != 0);
192
201
  if (cfg.warperType.empty()) cfg.warperType = "spherical";
193
202
  if (cfg.registrationResolMP <= 0.0) {
194
203
  cfg.registrationResolMP = 0.6;
@@ -210,13 +219,49 @@ Java_io_imagestitcher_rn_BatchStitcher_nativeStitchFramePaths(
210
219
 
211
220
  const std::string outPath = jstring_to_string(env, outputPath);
212
221
 
213
- retailens::StitchResult result = retailens::stitchFramePaths(
214
- paths, outPath, cfg, &androidLogBridge);
222
+ // 2026-06-16 (review #1) — backstop try/catch at the JNI C-ABI boundary.
223
+ // stitchFramePaths now has its own catch ladders (high-level + manual), so
224
+ // this should never fire — but a C++ exception crossing into JNI is UB
225
+ // (std::terminate/SIGABRT), so we NEVER let one through: convert any escape
226
+ // into a Java exception the Kotlin layer can catch.
227
+ retailens::StitchResult result;
228
+ try {
229
+ result = retailens::stitchFramePaths(
230
+ paths, outPath, cfg, &androidLogBridge);
231
+ } catch (const std::exception& e) {
232
+ throw_runtime(env, std::string("native stitch crashed: ") + e.what());
233
+ return nullptr;
234
+ } catch (...) {
235
+ throw_runtime(env, "native stitch crashed (unknown exception)");
236
+ return nullptr;
237
+ }
215
238
 
216
239
  // Return the stitch's freed native memory to the OS so the native-heap RSS
217
240
  // baseline doesn't ratchet up ~10-15 MB per capture (see purgeNativeAllocator).
218
- // Applies to BOTH pipelines (they share the OpenCV/bionic allocator).
219
- purgeNativeAllocator();
241
+ // Applies to BOTH pipelines (they share the OpenCV/bionic allocator). The
242
+ // post-purge RSS is the leak-plateau "floor" — append it to debugSummary so
243
+ // it rides the existing nativeLastDebugSummary() path to JS (no new bridge).
244
+ const double memFloor = purgeNativeAllocator(RNIS_MEMORY_PROFILING != 0);
245
+ if ((RNIS_MEMORY_PROFILING != 0) && memFloor >= 0.0) {
246
+ char fbuf[40];
247
+ std::snprintf(fbuf, sizeof(fbuf), ";memFloor=%.1f", memFloor);
248
+ if (!result.debugSummary.empty()) result.debugSummary += fbuf;
249
+ // 2026-06-16 — one authoritative per-stitch memory line to logcat (the
250
+ // sampler peak otherwise only rides debugSummary to the on-screen
251
+ // overlay). pipe/warp/mode lets each line be attributed to a preview
252
+ // tab: pipe=manual warp=plane mode=panorama = "As captured" primary;
253
+ // pipe=highlevel warp=plane = HL·Plane; warp=spherical = HL·Sph;
254
+ // mode=scans = SCANS. Grep `[memstat] record:` to harvest all of them.
255
+ LOGI("[memstat] record: pipe=%s warp=%s mode=%s before=%.1f peak=%.1f "
256
+ "after=%.1f floor=%.1f src=%s frames=%d/%d",
257
+ cfg.useManualPipeline ? "manual" : "highlevel",
258
+ cfg.warperType.c_str(),
259
+ (result.stitchModeUsed == retailens::StitchMode::Scans)
260
+ ? "scans" : "panorama",
261
+ result.memBeforeMB, result.memPeakMB, result.memAfterMB, memFloor,
262
+ result.memSource.c_str(),
263
+ result.framesIncluded, result.framesRequested);
264
+ }
220
265
 
221
266
  if (!result.success) {
222
267
  const std::string msg = "Stitch failed: " + result.errorMessage +
@@ -311,23 +311,30 @@ Java_io_imagestitcher_rn_KeyframeGate_nativeEvaluateWithFrame(
311
311
  }
312
312
  }
313
313
 
314
- // Pin the byte[] for the duration of the gate evaluate. Use
315
- // GetPrimitiveArrayCritical (zero-copy, JVM pins the GC) over
316
- // GetByteArrayElements (may copy on some VMs) because at 30-60
317
- // Hz of 2 MB Y-planes, the copy cost adds up. Evaluate is
318
- // ~1-5 ms so the pin window is short. Always paired with
319
- // ReleasePrimitiveArrayCritical even on the error paths below.
314
+ // 2026-06-16 (audit #4) — pin the byte[] ONLY to INGEST it.
315
+ // GetPrimitiveArrayCritical is zero-copy (the JVM pins the GC) — preferred
316
+ // over GetByteArrayElements (which may copy a 2 MB Y-plane at 30-60 Hz) —
317
+ // but it blocks the GC for the pin's whole life. So we keep that life to a
318
+ // single downscale: ingestWorkingFrame() reads the pinned bytes into an
319
+ // OWNED working frame, we Release IMMEDIATELY, then evaluateWithWorkingMat()
320
+ // runs the heavy OpenCV (goodFeaturesToTrack / optical flow) with the pin
321
+ // already gone — no longer stalling the GC or the frame-rate producer
322
+ // thread. Always paired with ReleasePrimitiveArrayCritical, even on errors.
320
323
  retailens::KeyframeGateDecision d;
321
324
  if (grayBytes && grayWidth > 0 && grayHeight > 0 && grayStride >= grayWidth) {
322
325
  void* raw = env->GetPrimitiveArrayCritical(grayBytes, nullptr);
323
326
  if (raw) {
324
- d = gate(handle)->evaluateWithFrame(
325
- pose, planePtr,
327
+ gate(handle)->ingestWorkingFrame(
326
328
  static_cast<const uint8_t*>(raw),
327
329
  static_cast<int32_t>(grayWidth),
328
330
  static_cast<int32_t>(grayHeight),
329
331
  static_cast<int32_t>(grayStride));
330
332
  env->ReleasePrimitiveArrayCritical(grayBytes, raw, JNI_ABORT);
333
+ // Pin released — heavy OpenCV now runs outside the critical section.
334
+ d = gate(handle)->evaluateWithWorkingMat(
335
+ pose, planePtr,
336
+ static_cast<int32_t>(grayWidth),
337
+ static_cast<int32_t>(grayHeight));
331
338
  } else {
332
339
  // GetPrimitiveArrayCritical failed (rare, but defensive).
333
340
  // Fall back to pose-only path so we degrade gracefully