react-native-image-stitcher 0.14.1 → 0.15.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 (119) hide show
  1. package/CHANGELOG.md +160 -0
  2. package/README.md +35 -0
  3. package/RNImageStitcher.podspec +8 -1
  4. package/android/build.gradle +0 -16
  5. package/android/src/main/cpp/CMakeLists.txt +2 -63
  6. package/android/src/main/cpp/image_stitcher_jni.cpp +14 -0
  7. package/android/src/main/cpp/keyframe_gate_jni.cpp +13 -0
  8. package/android/src/main/java/io/imagestitcher/rn/BatchStitcher.kt +285 -3
  9. package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +180 -1162
  10. package/android/src/main/java/io/imagestitcher/rn/KeyframeGate.kt +29 -0
  11. package/android/src/main/java/io/imagestitcher/rn/RNImageStitcherPackage.kt +0 -4
  12. package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +13 -64
  13. package/cpp/keyframe_gate.cpp +82 -23
  14. package/cpp/keyframe_gate.hpp +31 -2
  15. package/cpp/stitcher.cpp +208 -28
  16. package/cpp/tests/CMakeLists.txt +18 -12
  17. package/cpp/tests/keyframe_timebudget_test.cpp +65 -0
  18. package/cpp/tests/warp_guard_test.cpp +48 -0
  19. package/cpp/warp_guard.hpp +41 -0
  20. package/dist/ar/useARSession.d.ts +9 -0
  21. package/dist/ar/useARSession.js +24 -2
  22. package/dist/camera/Camera.d.ts +31 -16
  23. package/dist/camera/Camera.js +27 -4
  24. package/dist/camera/CaptureStitchStatsToast.d.ts +15 -2
  25. package/dist/camera/CaptureStitchStatsToast.js +27 -7
  26. package/dist/camera/PanoramaSettings.d.ts +10 -223
  27. package/dist/camera/PanoramaSettings.js +6 -28
  28. package/dist/camera/PanoramaSettingsBridge.d.ts +1 -24
  29. package/dist/camera/PanoramaSettingsBridge.js +3 -102
  30. package/dist/camera/PanoramaSettingsModal.js +7 -1
  31. package/dist/camera/buildPanoramaInitialSettings.d.ts +11 -0
  32. package/dist/camera/buildPanoramaInitialSettings.js +4 -0
  33. package/dist/camera/cameraErrorMessages.d.ts +32 -0
  34. package/dist/camera/cameraErrorMessages.js +53 -0
  35. package/dist/camera/selectCaptureDevice.d.ts +5 -1
  36. package/dist/camera/selectCaptureDevice.js +22 -2
  37. package/dist/camera/useCapture.js +38 -0
  38. package/dist/index.d.ts +5 -8
  39. package/dist/index.js +11 -34
  40. package/dist/stitching/incremental.d.ts +1 -117
  41. package/dist/stitching/stitchVideo.d.ts +0 -35
  42. package/dist/types.d.ts +0 -87
  43. package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +96 -674
  44. package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.swift +9 -12
  45. package/ios/Sources/RNImageStitcher/KeyframeGate.swift +14 -0
  46. package/ios/Sources/RNImageStitcher/KeyframeGateBridge.h +7 -0
  47. package/ios/Sources/RNImageStitcher/KeyframeGateBridge.mm +6 -0
  48. package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.h +2 -2
  49. package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.mm +3 -3
  50. package/ios/Sources/RNImageStitcher/OpenCVStitcher.h +28 -60
  51. package/ios/Sources/RNImageStitcher/OpenCVStitcher.mm +180 -921
  52. package/ios/Sources/RNImageStitcher/RNSARSession.swift +10 -35
  53. package/ios/Sources/RNImageStitcher/Stitcher.swift +84 -35
  54. package/ios/Sources/RNImageStitcher/StitcherBridge.m +13 -0
  55. package/ios/Sources/RNImageStitcher/StitcherBridge.swift +132 -5
  56. package/package.json +3 -2
  57. package/src/ar/useARSession.ts +35 -5
  58. package/src/camera/Camera.tsx +63 -24
  59. package/src/camera/CaptureStitchStatsToast.tsx +58 -14
  60. package/src/camera/PanoramaSettings.ts +16 -289
  61. package/src/camera/PanoramaSettingsBridge.ts +3 -114
  62. package/src/camera/PanoramaSettingsModal.tsx +14 -1
  63. package/src/camera/__tests__/PanoramaSettingsBridge.test.ts +3 -188
  64. package/src/camera/__tests__/buildPanoramaInitialSettings.test.ts +41 -0
  65. package/src/camera/__tests__/cameraErrorMessages.test.ts +76 -0
  66. package/src/camera/__tests__/selectCaptureDevice.test.ts +33 -0
  67. package/src/camera/buildPanoramaInitialSettings.ts +17 -0
  68. package/src/camera/cameraErrorMessages.ts +84 -0
  69. package/src/camera/selectCaptureDevice.ts +28 -3
  70. package/src/camera/useCapture.ts +44 -1
  71. package/src/index.ts +11 -40
  72. package/src/stitching/incremental.ts +3 -140
  73. package/src/stitching/stitchVideo.ts +0 -26
  74. package/src/types.ts +0 -95
  75. package/android/src/main/cpp/stitcher_jsi_install_jni.cpp +0 -227
  76. package/android/src/main/java/io/imagestitcher/rn/IncrementalFirstwinsEngine.kt +0 -1081
  77. package/android/src/main/java/io/imagestitcher/rn/StitcherJsiInstallerModule.kt +0 -103
  78. package/android/src/main/java/io/imagestitcher/rn/StitcherWorkletRuntime.kt +0 -256
  79. package/cpp/stitcher_frame_jsi.cpp +0 -214
  80. package/cpp/stitcher_frame_jsi.hpp +0 -108
  81. package/cpp/stitcher_proxy_jsi.cpp +0 -109
  82. package/cpp/stitcher_proxy_jsi.hpp +0 -46
  83. package/cpp/stitcher_worklet_dispatch.cpp +0 -103
  84. package/cpp/stitcher_worklet_dispatch.hpp +0 -71
  85. package/cpp/stitcher_worklet_registry.cpp +0 -91
  86. package/cpp/stitcher_worklet_registry.hpp +0 -146
  87. package/cpp/tests/stitcher_worklet_registry_test.cpp +0 -195
  88. package/dist/stitching/IncrementalStitcherView.d.ts +0 -41
  89. package/dist/stitching/IncrementalStitcherView.js +0 -157
  90. package/dist/stitching/StitcherWorkletRegistry.d.ts +0 -117
  91. package/dist/stitching/StitcherWorkletRegistry.js +0 -78
  92. package/dist/stitching/ensureStitcherProxyInstalled.d.ts +0 -8
  93. package/dist/stitching/ensureStitcherProxyInstalled.js +0 -81
  94. package/dist/stitching/useFrameProcessor.d.ts +0 -119
  95. package/dist/stitching/useFrameProcessor.js +0 -196
  96. package/dist/stitching/useFrameStream.d.ts +0 -34
  97. package/dist/stitching/useFrameStream.js +0 -234
  98. package/dist/stitching/useThrottledFrameProcessor.d.ts +0 -33
  99. package/dist/stitching/useThrottledFrameProcessor.js +0 -132
  100. package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.h +0 -474
  101. package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.mm +0 -1328
  102. package/ios/Sources/RNImageStitcher/OpenCVSlitScanStitcher.h +0 -103
  103. package/ios/Sources/RNImageStitcher/OpenCVSlitScanStitcher.mm +0 -3285
  104. package/ios/Sources/RNImageStitcher/RNSARWorkletRuntime.h +0 -128
  105. package/ios/Sources/RNImageStitcher/RNSARWorkletRuntime.mm +0 -313
  106. package/ios/Sources/RNImageStitcher/SaveFrameAsJpegPlugin.mm +0 -185
  107. package/ios/Sources/RNImageStitcher/StitcherFrameHostObject.h +0 -60
  108. package/ios/Sources/RNImageStitcher/StitcherFrameHostObject.mm +0 -214
  109. package/ios/Sources/RNImageStitcher/StitcherJsiInstaller.h +0 -42
  110. package/ios/Sources/RNImageStitcher/StitcherJsiInstaller.mm +0 -160
  111. package/src/stitching/IncrementalStitcherView.tsx +0 -198
  112. package/src/stitching/StitcherWorkletRegistry.ts +0 -156
  113. package/src/stitching/__tests__/StitcherWorkletRegistry.test.ts +0 -176
  114. package/src/stitching/__tests__/ensureStitcherProxyInstalled.test.ts +0 -94
  115. package/src/stitching/__tests__/useThrottledFrameProcessor.test.ts +0 -178
  116. package/src/stitching/ensureStitcherProxyInstalled.ts +0 -141
  117. package/src/stitching/useFrameProcessor.ts +0 -226
  118. package/src/stitching/useFrameStream.ts +0 -271
  119. package/src/stitching/useThrottledFrameProcessor.ts +0 -145
@@ -1,156 +0,0 @@
1
- // SPDX-License-Identifier: Apache-2.0
2
-
3
- import type { StitcherFrameProcessor } from './StitcherFrame';
4
-
5
- /**
6
- * v0.8.0 Phase 4a — process-scope registry of host-supplied worklets
7
- * that the v0.8.0 `useFrameProcessor` hook registers into.
8
- *
9
- * ## What this is (Phase 4a)
10
- *
11
- * A plain JS singleton holding an ordered list of registered
12
- * worklets. Hosts mount the `useFrameProcessor` hook (in this
13
- * directory); the hook registers its worklet into this singleton
14
- * on mount and unregisters on unmount. Each entry carries:
15
- *
16
- * - `id`: stable identifier issued by `register`; passed to
17
- * `unregister`.
18
- * - `worklet`: the host's `StitcherFrameProcessor` function.
19
- * MUST be `'worklet'`-prefixed at the call site (TS can't
20
- * enforce that — convention).
21
- * - `isFirstParty`: `false` for host-supplied worklets;
22
- * reserved for the lib's own first-party stitching path which
23
- * today is wired natively (not through this registry).
24
- *
25
- * Order is stable: first-party entries (none in Phase 4a) come
26
- * first, then host entries by registration order. Re-registration
27
- * of the same worklet by identity yields a new entry — hosts that
28
- * re-render and call `register` again ARE responsible for calling
29
- * `unregister` first. The `useFrameProcessor` hook handles this
30
- * via its `deps` dependency array.
31
- *
32
- * ## What this is NOT (Phase 4b)
33
- *
34
- * **The native AR worklet runtime does NOT yet read this registry.**
35
- * Worklets registered here for AR-mode captures will not fire
36
- * until Phase 4b lands the cross-runtime handoff (a
37
- * worklets-core `SharedValue` mirror that `RNSARWorkletRuntime`
38
- * reads on each `dispatchFrame:pose:` call; the runtime then
39
- * constructs a `StitcherFrameHostObject` + invokes each
40
- * registered worklet via `RNWorklet::WorkletInvoker::call`).
41
- *
42
- * In non-AR mode the host-supplied worklet IS invoked, but via
43
- * vision-camera's Frame Processor runtime directly (the
44
- * `useFrameProcessor` hook returns vc's processor object which
45
- * `<Camera>` passes to vision-camera). So Phase 4a's public API
46
- * is fully functional for non-AR; AR is API-stable but
47
- * runtime-deferred.
48
- *
49
- * ## Singleton lifetime
50
- *
51
- * The registry is a module-level instance. It lives for the
52
- * lifetime of the JS runtime (= until app reload). Entries
53
- * accumulate only via `register` and shed only via `unregister`
54
- * — no GC / weak-ref logic. Hosts that mount `useFrameProcessor`
55
- * inside React components MUST rely on the hook's effect cleanup
56
- * to unregister on unmount, or they'll leak entries until
57
- * reload. The hook handles this correctly today.
58
- *
59
- * ## Why a singleton (vs context provider)
60
- *
61
- * The native AR worklet runtime is itself a process-scope
62
- * singleton (`RNSARWorkletRuntime`, `StitcherWorkletRuntime`).
63
- * The Phase 4b handoff between TS and native is necessarily
64
- * process-scope. Wrapping the registry in a React context
65
- * would force every consumer to be in the same provider tree
66
- * which is friction for layer-2 hosts that compose
67
- * `<ARCameraView>` / `useIncrementalStitcher` themselves. The
68
- * singleton is the right shape; the React-level ergonomics are
69
- * provided by the `useFrameProcessor` hook.
70
- */
71
- export interface StitcherWorkletEntry {
72
- readonly id: string;
73
- readonly worklet: StitcherFrameProcessor;
74
- readonly isFirstParty: boolean;
75
- }
76
-
77
- class Registry {
78
- private entries: StitcherWorkletEntry[] = [];
79
- private nextHostCounter = 0;
80
-
81
- /**
82
- * Register a worklet. Returns a stable ID for `unregister`.
83
- *
84
- * Entries are appended in registration order; first-party
85
- * entries (if any are added in future) sort to the front.
86
- */
87
- register(opts: {
88
- worklet: StitcherFrameProcessor;
89
- isFirstParty?: boolean;
90
- }): string {
91
- const isFirstParty = opts.isFirstParty ?? false;
92
- const id = isFirstParty
93
- ? `fp-${this.nextHostCounter++}`
94
- : `host-${this.nextHostCounter++}`;
95
- const entry: StitcherWorkletEntry = {
96
- id,
97
- worklet: opts.worklet,
98
- isFirstParty,
99
- };
100
- this.entries.push(entry);
101
- // Re-sort so first-party always runs before host entries.
102
- // Stable sort: registration order is preserved within each
103
- // partition. Single-pass O(n log n) is fine — registration
104
- // is rare (per-`<Camera>`-mount, not per-frame).
105
- this.entries.sort((a, b) => {
106
- if (a.isFirstParty !== b.isFirstParty) {
107
- return a.isFirstParty ? -1 : 1;
108
- }
109
- return 0;
110
- });
111
- return id;
112
- }
113
-
114
- /**
115
- * Remove a previously-registered worklet by ID. No-op if the ID
116
- * isn't found. Hosts call this in their effect's cleanup.
117
- */
118
- unregister(id: string): void {
119
- this.entries = this.entries.filter((e) => e.id !== id);
120
- }
121
-
122
- /**
123
- * Snapshot the current entries. Returned array is a copy —
124
- * mutations don't affect the registry. Phase 4b's native
125
- * handoff will read a `SharedValue` mirror of this list so the
126
- * AR runtime doesn't need a JS-thread hop on the hot per-frame
127
- * path; for Phase 4a this method is the JS-side accessor.
128
- */
129
- getEntries(): readonly StitcherWorkletEntry[] {
130
- return [...this.entries];
131
- }
132
-
133
- /**
134
- * Total number of registered worklets (first-party + host).
135
- * Useful for diagnostics + tests.
136
- */
137
- get count(): number {
138
- return this.entries.length;
139
- }
140
-
141
- /**
142
- * Test-only — clear all entries. NOT exported from
143
- * `src/index.ts`. Used in unit tests to reset state between
144
- * cases.
145
- */
146
- _resetForTests(): void {
147
- this.entries = [];
148
- this.nextHostCounter = 0;
149
- }
150
- }
151
-
152
- /**
153
- * Process-scope singleton. Imported by `useFrameProcessor` (in
154
- * this directory) + by the Phase 4b native-handoff code (TBD).
155
- */
156
- export const StitcherWorkletRegistry = new Registry();
@@ -1,176 +0,0 @@
1
- // SPDX-License-Identifier: Apache-2.0
2
- /**
3
- * Unit tests for the v0.8.0 Phase 4a `StitcherWorkletRegistry` singleton.
4
- *
5
- * The registry is the JS-side staging area for host worklets — the
6
- * Phase 4b native handoff will read from it to fan out invocations on
7
- * the AR runtime. These tests pin the invariants the native handoff
8
- * is going to rely on:
9
- *
10
- * - Stable, unique IDs out of `register`.
11
- * - Registration order preserved within the host-entry partition.
12
- * - First-party entries always sort to the front of `getEntries`.
13
- * - `unregister` is a no-op for unknown IDs (no throw — the native
14
- * handoff may race a JS-side unregister with a frame in flight).
15
- * - `getEntries` returns a snapshot — mutating the returned array
16
- * can't corrupt registry state.
17
- * - `_resetForTests` returns the registry to a pristine state
18
- * (used by these tests; documented as test-only in the source).
19
- */
20
-
21
- import { StitcherWorkletRegistry } from '../StitcherWorkletRegistry';
22
- import type { StitcherFrameProcessor } from '../StitcherFrame';
23
-
24
- // Fresh no-op worklet stubs. These are NOT real worklets — they have
25
- // no `'worklet'` directive — but the registry doesn't care about
26
- // invocation, only about identity + ordering.
27
- const makeWorklet = (label: string): StitcherFrameProcessor => {
28
- const fn = (_frame: unknown) => {
29
- void label;
30
- };
31
- return fn as unknown as StitcherFrameProcessor;
32
- };
33
-
34
- describe('StitcherWorkletRegistry', () => {
35
- beforeEach(() => {
36
- StitcherWorkletRegistry._resetForTests();
37
- });
38
-
39
- describe('register', () => {
40
- it('returns a non-empty string ID', () => {
41
- const id = StitcherWorkletRegistry.register({ worklet: makeWorklet('a') });
42
- expect(typeof id).toBe('string');
43
- expect(id.length).toBeGreaterThan(0);
44
- });
45
-
46
- it('issues distinct IDs across calls', () => {
47
- const id1 = StitcherWorkletRegistry.register({ worklet: makeWorklet('a') });
48
- const id2 = StitcherWorkletRegistry.register({ worklet: makeWorklet('b') });
49
- expect(id1).not.toBe(id2);
50
- });
51
-
52
- it('issues distinct IDs even for the same worklet identity', () => {
53
- // Hosts that re-render and re-register without unregistering get
54
- // a fresh slot — the hook itself handles cleanup via deps, but
55
- // the registry treats each `register` as independent.
56
- const w = makeWorklet('shared');
57
- const id1 = StitcherWorkletRegistry.register({ worklet: w });
58
- const id2 = StitcherWorkletRegistry.register({ worklet: w });
59
- expect(id1).not.toBe(id2);
60
- expect(StitcherWorkletRegistry.count).toBe(2);
61
- });
62
-
63
- it('host entries default to isFirstParty=false', () => {
64
- StitcherWorkletRegistry.register({ worklet: makeWorklet('host') });
65
- const [entry] = StitcherWorkletRegistry.getEntries();
66
- expect(entry.isFirstParty).toBe(false);
67
- });
68
-
69
- it('first-party flag passes through to the entry', () => {
70
- StitcherWorkletRegistry.register({
71
- worklet: makeWorklet('fp'),
72
- isFirstParty: true,
73
- });
74
- const [entry] = StitcherWorkletRegistry.getEntries();
75
- expect(entry.isFirstParty).toBe(true);
76
- });
77
- });
78
-
79
- describe('getEntries ordering', () => {
80
- it('preserves host registration order when no first-party entries', () => {
81
- const wa = makeWorklet('a');
82
- const wb = makeWorklet('b');
83
- const wc = makeWorklet('c');
84
- StitcherWorkletRegistry.register({ worklet: wa });
85
- StitcherWorkletRegistry.register({ worklet: wb });
86
- StitcherWorkletRegistry.register({ worklet: wc });
87
- const entries = StitcherWorkletRegistry.getEntries();
88
- expect(entries.map((e) => e.worklet)).toEqual([wa, wb, wc]);
89
- });
90
-
91
- it('sorts first-party entries before host entries regardless of registration order', () => {
92
- const host1 = makeWorklet('host1');
93
- const fp1 = makeWorklet('fp1');
94
- const host2 = makeWorklet('host2');
95
- const fp2 = makeWorklet('fp2');
96
- // Interleave registrations.
97
- StitcherWorkletRegistry.register({ worklet: host1 });
98
- StitcherWorkletRegistry.register({ worklet: fp1, isFirstParty: true });
99
- StitcherWorkletRegistry.register({ worklet: host2 });
100
- StitcherWorkletRegistry.register({ worklet: fp2, isFirstParty: true });
101
- const entries = StitcherWorkletRegistry.getEntries();
102
- // First-party block first (in registration order),
103
- // then host block (in registration order).
104
- expect(entries.map((e) => e.worklet)).toEqual([fp1, fp2, host1, host2]);
105
- });
106
-
107
- it('returns a snapshot — mutating the returned array does not affect the registry', () => {
108
- StitcherWorkletRegistry.register({ worklet: makeWorklet('a') });
109
- const entries = StitcherWorkletRegistry.getEntries();
110
- // Cast away readonly so we can attempt a mutation.
111
- (entries as unknown as unknown[]).push({} as never);
112
- // Registry's own count is unchanged.
113
- expect(StitcherWorkletRegistry.count).toBe(1);
114
- expect(StitcherWorkletRegistry.getEntries()).toHaveLength(1);
115
- });
116
- });
117
-
118
- describe('unregister', () => {
119
- it('removes a previously-registered entry by ID', () => {
120
- const id = StitcherWorkletRegistry.register({ worklet: makeWorklet('a') });
121
- expect(StitcherWorkletRegistry.count).toBe(1);
122
- StitcherWorkletRegistry.unregister(id);
123
- expect(StitcherWorkletRegistry.count).toBe(0);
124
- });
125
-
126
- it('is a no-op for an unknown ID (no throw)', () => {
127
- StitcherWorkletRegistry.register({ worklet: makeWorklet('a') });
128
- expect(() => StitcherWorkletRegistry.unregister('host-9999')).not.toThrow();
129
- expect(StitcherWorkletRegistry.count).toBe(1);
130
- });
131
-
132
- it('removes the right entry when multiple are registered', () => {
133
- const id1 = StitcherWorkletRegistry.register({ worklet: makeWorklet('a') });
134
- const id2 = StitcherWorkletRegistry.register({ worklet: makeWorklet('b') });
135
- const id3 = StitcherWorkletRegistry.register({ worklet: makeWorklet('c') });
136
- StitcherWorkletRegistry.unregister(id2);
137
- const remainingIds = StitcherWorkletRegistry.getEntries().map((e) => e.id);
138
- expect(remainingIds).toEqual([id1, id3]);
139
- });
140
-
141
- it('survives double-unregister of the same ID', () => {
142
- const id = StitcherWorkletRegistry.register({ worklet: makeWorklet('a') });
143
- StitcherWorkletRegistry.unregister(id);
144
- expect(() => StitcherWorkletRegistry.unregister(id)).not.toThrow();
145
- expect(StitcherWorkletRegistry.count).toBe(0);
146
- });
147
- });
148
-
149
- describe('count', () => {
150
- it('starts at 0 after reset', () => {
151
- expect(StitcherWorkletRegistry.count).toBe(0);
152
- });
153
-
154
- it('reflects register/unregister deltas', () => {
155
- expect(StitcherWorkletRegistry.count).toBe(0);
156
- const id1 = StitcherWorkletRegistry.register({ worklet: makeWorklet('a') });
157
- expect(StitcherWorkletRegistry.count).toBe(1);
158
- StitcherWorkletRegistry.register({ worklet: makeWorklet('b') });
159
- expect(StitcherWorkletRegistry.count).toBe(2);
160
- StitcherWorkletRegistry.unregister(id1);
161
- expect(StitcherWorkletRegistry.count).toBe(1);
162
- });
163
- });
164
-
165
- describe('_resetForTests', () => {
166
- it('clears all entries', () => {
167
- StitcherWorkletRegistry.register({ worklet: makeWorklet('a') });
168
- StitcherWorkletRegistry.register({ worklet: makeWorklet('b'), isFirstParty: true });
169
- StitcherWorkletRegistry.register({ worklet: makeWorklet('c') });
170
- expect(StitcherWorkletRegistry.count).toBe(3);
171
- StitcherWorkletRegistry._resetForTests();
172
- expect(StitcherWorkletRegistry.count).toBe(0);
173
- expect(StitcherWorkletRegistry.getEntries()).toEqual([]);
174
- });
175
- });
176
- });
@@ -1,94 +0,0 @@
1
- // SPDX-License-Identifier: Apache-2.0
2
- /**
3
- * Unit tests for the v0.8.0 Phase 4b `ensureStitcherProxyInstalled`
4
- * helper. The native install path can't be exercised in jest (no
5
- * JSI runtime), so these tests cover only the JS-side branches:
6
- *
7
- * - Idempotency (second call short-circuits).
8
- * - "module missing" path returns false + warns once.
9
- * - "install returns false" path returns false + warns once.
10
- * - "install throws" path returns false + warns once.
11
- * - "install succeeds" path returns true + caches.
12
- *
13
- * NativeModules is stubbed via the jest mock at
14
- * `jest.mocks/react-native.js`; per-test customization swaps in a
15
- * scenario-specific module.
16
- */
17
-
18
- import { NativeModules } from 'react-native';
19
-
20
- import {
21
- _resetStitcherProxyInstallStateForTests,
22
- ensureStitcherProxyInstalled,
23
- } from '../ensureStitcherProxyInstalled';
24
-
25
- type MutableGlobal = Record<string, unknown>;
26
-
27
- describe('ensureStitcherProxyInstalled', () => {
28
- beforeEach(() => {
29
- _resetStitcherProxyInstallStateForTests();
30
- // Clear any previous __stitcherProxy from prior test cases so the
31
- // module's "already installed" fast-path doesn't bypass our
32
- // scenario-specific NativeModules stub.
33
- delete (globalThis as MutableGlobal).__stitcherProxy;
34
- // Wipe the NativeModules.StitcherJsiInstaller entry so each test
35
- // starts with a clean slate (the mock module is a shared object
36
- // across the whole test file).
37
- (NativeModules as MutableGlobal).StitcherJsiInstaller = undefined;
38
- });
39
-
40
- it('returns false when the native module is missing', () => {
41
- expect(ensureStitcherProxyInstalled()).toBe(false);
42
- });
43
-
44
- it('returns true when install() returns true', () => {
45
- const install = jest.fn(() => true);
46
- (NativeModules as MutableGlobal).StitcherJsiInstaller = { install };
47
- expect(ensureStitcherProxyInstalled()).toBe(true);
48
- expect(install).toHaveBeenCalledTimes(1);
49
- });
50
-
51
- it('returns false when install() returns false', () => {
52
- const install = jest.fn(() => false);
53
- (NativeModules as MutableGlobal).StitcherJsiInstaller = { install };
54
- expect(ensureStitcherProxyInstalled()).toBe(false);
55
- expect(install).toHaveBeenCalledTimes(1);
56
- });
57
-
58
- it('returns false when install() throws', () => {
59
- const install = jest.fn(() => {
60
- throw new Error('boom');
61
- });
62
- (NativeModules as MutableGlobal).StitcherJsiInstaller = { install };
63
- expect(ensureStitcherProxyInstalled()).toBe(false);
64
- expect(install).toHaveBeenCalledTimes(1);
65
- });
66
-
67
- it('is idempotent — second call short-circuits without re-invoking install()', () => {
68
- const install = jest.fn(() => true);
69
- (NativeModules as MutableGlobal).StitcherJsiInstaller = { install };
70
- expect(ensureStitcherProxyInstalled()).toBe(true);
71
- expect(ensureStitcherProxyInstalled()).toBe(true);
72
- expect(install).toHaveBeenCalledTimes(1);
73
- });
74
-
75
- it('short-circuits if __stitcherProxy is already on globalThis (e.g., other consumer installed it first)', () => {
76
- // Simulate a different SDK instance having installed the proxy.
77
- (globalThis as MutableGlobal).__stitcherProxy = {
78
- install: jest.fn(),
79
- uninstall: jest.fn(),
80
- count: jest.fn(() => 0),
81
- };
82
- const install = jest.fn();
83
- (NativeModules as MutableGlobal).StitcherJsiInstaller = { install };
84
- expect(ensureStitcherProxyInstalled()).toBe(true);
85
- // We did NOT call our own native install — we accepted the
86
- // already-installed proxy.
87
- expect(install).not.toHaveBeenCalled();
88
- });
89
-
90
- it('treats `install: undefined` as a missing module (not a crash)', () => {
91
- (NativeModules as MutableGlobal).StitcherJsiInstaller = {};
92
- expect(ensureStitcherProxyInstalled()).toBe(false);
93
- });
94
- });
@@ -1,178 +0,0 @@
1
- // SPDX-License-Identifier: Apache-2.0
2
- /**
3
- * Unit tests for the v0.9.0 Layer 2 `useThrottledFrameProcessor` hook.
4
- *
5
- * The worklet runtime can't run in jest (no JSI, no worklets-core).
6
- * What we CAN test:
7
- *
8
- * - The `sampleHz` clamping (`[0.5, 30]`)
9
- * - `minIntervalMs` math (1000 / sampleHz)
10
- * - The deps propagation (host's deps → useFrameProcessor's deps)
11
- * - The throttle gate logic (extracted as a pure function for
12
- * isolated verification — see `_throttleGateForTests`).
13
- *
14
- * The hook itself is tested via a thin React-renderer-free harness:
15
- * we mock `useFrameProcessor` + `useSharedValue` so we can verify
16
- * the call shape without booting the worklet runtime.
17
- */
18
-
19
- import { useThrottledFrameProcessor } from '../useThrottledFrameProcessor';
20
-
21
- // ─── Mock vision-camera + worklets-core ─────────────────────────────
22
- // These are minimal-shim mocks — enough surface for the hook to call
23
- // `useFrameProcessor(workletBody, deps)` and `useSharedValue(0)`.
24
-
25
- const useFrameProcessorMock = jest.fn();
26
- const useSharedValueMock = jest.fn();
27
-
28
- jest.mock('../useFrameProcessor', () => ({
29
- useFrameProcessor: (...args: unknown[]) => useFrameProcessorMock(...args),
30
- }));
31
-
32
- jest.mock('react-native-worklets-core', () => ({
33
- useSharedValue: (initial: number) => useSharedValueMock(initial),
34
- }));
35
-
36
- describe('useThrottledFrameProcessor', () => {
37
- beforeEach(() => {
38
- useFrameProcessorMock.mockReset();
39
- useSharedValueMock.mockReset();
40
- // Default behaviour for useSharedValue: return an object with a
41
- // mutable `.value` field (mirrors worklets-core's API).
42
- useSharedValueMock.mockImplementation((initial: number) => ({
43
- value: initial,
44
- }));
45
- });
46
-
47
- describe('sampleHz clamping', () => {
48
- it('clamps below 0.5 to 0.5', () => {
49
- const noop = (() => {}) as unknown as Parameters<typeof useThrottledFrameProcessor>[0];
50
- useThrottledFrameProcessor(noop, { sampleHz: 0.1 }, []);
51
- // useFrameProcessor receives the wrapped worklet; the deps
52
- // array's first entry is `minIntervalMs`. For sampleHz=0.5,
53
- // minIntervalMs = 2000.
54
- const [, deps] = useFrameProcessorMock.mock.calls[0]!;
55
- expect(deps[0]).toBeCloseTo(2000);
56
- });
57
-
58
- it('clamps above 30 to 30', () => {
59
- const noop = (() => {}) as unknown as Parameters<typeof useThrottledFrameProcessor>[0];
60
- useThrottledFrameProcessor(noop, { sampleHz: 999 }, []);
61
- const [, deps] = useFrameProcessorMock.mock.calls[0]!;
62
- // sampleHz=30 → minIntervalMs = 33.333...
63
- expect(deps[0]).toBeCloseTo(1000 / 30);
64
- });
65
-
66
- it('passes through in-range sampleHz unchanged', () => {
67
- const noop = (() => {}) as unknown as Parameters<typeof useThrottledFrameProcessor>[0];
68
- useThrottledFrameProcessor(noop, { sampleHz: 2 }, []);
69
- const [, deps] = useFrameProcessorMock.mock.calls[0]!;
70
- expect(deps[0]).toBeCloseTo(500);
71
- });
72
-
73
- it('accepts boundary values exactly', () => {
74
- const noop = (() => {}) as unknown as Parameters<typeof useThrottledFrameProcessor>[0];
75
- useThrottledFrameProcessor(noop, { sampleHz: 0.5 }, []);
76
- let deps = useFrameProcessorMock.mock.calls[0]![1];
77
- expect(deps[0]).toBeCloseTo(2000);
78
-
79
- useFrameProcessorMock.mockClear();
80
- useThrottledFrameProcessor(noop, { sampleHz: 30 }, []);
81
- deps = useFrameProcessorMock.mock.calls[0]![1];
82
- expect(deps[0]).toBeCloseTo(1000 / 30);
83
- });
84
- });
85
-
86
- describe('deps propagation', () => {
87
- it('appends host deps after the internal interval + worklet deps', () => {
88
- const noop = (() => {}) as unknown as Parameters<typeof useThrottledFrameProcessor>[0];
89
- const hostDep1 = { id: 'a' };
90
- const hostDep2 = 42;
91
- useThrottledFrameProcessor(noop, { sampleHz: 2 }, [hostDep1, hostDep2]);
92
- const [, deps] = useFrameProcessorMock.mock.calls[0]!;
93
- // Expected shape: [minIntervalMs, worklet, ...hostDeps]
94
- expect(deps).toHaveLength(4);
95
- expect(deps[0]).toBeCloseTo(500);
96
- expect(deps[1]).toBe(noop);
97
- expect(deps[2]).toBe(hostDep1);
98
- expect(deps[3]).toBe(hostDep2);
99
- });
100
-
101
- it('with empty host deps: deps = [minIntervalMs, worklet]', () => {
102
- const noop = (() => {}) as unknown as Parameters<typeof useThrottledFrameProcessor>[0];
103
- useThrottledFrameProcessor(noop, { sampleHz: 2 }, []);
104
- const [, deps] = useFrameProcessorMock.mock.calls[0]!;
105
- expect(deps).toHaveLength(2);
106
- expect(deps[0]).toBeCloseTo(500);
107
- expect(deps[1]).toBe(noop);
108
- });
109
- });
110
-
111
- describe('throttle gate', () => {
112
- // The throttle logic lives INSIDE the wrapped worklet body, which
113
- // jest can't execute directly (it's a `'worklet'`-prefixed
114
- // function). But the wrapped function IS just a plain JS
115
- // function until the worklets-core babel plugin transforms it,
116
- // so we can call it manually with mock frames + a mock
117
- // shared-value gate.
118
- //
119
- // The body's logic:
120
- // if (frame.timestamp - lastSampleMs.value < minIntervalMs) return;
121
- // lastSampleMs.value = frame.timestamp;
122
- // worklet(frame);
123
-
124
- it('fires the worklet on the first frame regardless of timestamp', () => {
125
- const hostWorklet = jest.fn();
126
- useThrottledFrameProcessor(hostWorklet, { sampleHz: 2 }, []);
127
- const [wrappedBody] = useFrameProcessorMock.mock.calls[0]!;
128
-
129
- const frame = { timestamp: 12345 } as Parameters<typeof hostWorklet>[0];
130
- wrappedBody(frame);
131
-
132
- expect(hostWorklet).toHaveBeenCalledTimes(1);
133
- expect(hostWorklet).toHaveBeenCalledWith(frame);
134
- });
135
-
136
- it('skips a frame too close to the previous sample', () => {
137
- const hostWorklet = jest.fn();
138
- useThrottledFrameProcessor(hostWorklet, { sampleHz: 2 }, []); // 500ms interval
139
- const [wrappedBody] = useFrameProcessorMock.mock.calls[0]!;
140
-
141
- wrappedBody({ timestamp: 1000 } as never);
142
- wrappedBody({ timestamp: 1100 } as never); // 100ms after — too soon
143
- wrappedBody({ timestamp: 1200 } as never); // 200ms after — too soon
144
-
145
- expect(hostWorklet).toHaveBeenCalledTimes(1);
146
- });
147
-
148
- it('fires again exactly at the interval boundary', () => {
149
- const hostWorklet = jest.fn();
150
- useThrottledFrameProcessor(hostWorklet, { sampleHz: 2 }, []); // 500ms
151
- const [wrappedBody] = useFrameProcessorMock.mock.calls[0]!;
152
-
153
- wrappedBody({ timestamp: 1000 } as never);
154
- wrappedBody({ timestamp: 1500 } as never); // exactly at boundary
155
-
156
- expect(hostWorklet).toHaveBeenCalledTimes(2);
157
- });
158
-
159
- it('fires again past the interval boundary', () => {
160
- const hostWorklet = jest.fn();
161
- useThrottledFrameProcessor(hostWorklet, { sampleHz: 2 }, []); // 500ms
162
- const [wrappedBody] = useFrameProcessorMock.mock.calls[0]!;
163
-
164
- wrappedBody({ timestamp: 1000 } as never);
165
- wrappedBody({ timestamp: 1600 } as never); // 600ms after
166
-
167
- expect(hostWorklet).toHaveBeenCalledTimes(2);
168
- });
169
- });
170
-
171
- describe('shared value lifecycle', () => {
172
- it('initializes lastSampleMs to 0', () => {
173
- const noop = (() => {}) as unknown as Parameters<typeof useThrottledFrameProcessor>[0];
174
- useThrottledFrameProcessor(noop, { sampleHz: 2 }, []);
175
- expect(useSharedValueMock).toHaveBeenCalledWith(0);
176
- });
177
- });
178
- });