react-native-image-stitcher 0.7.1 → 0.9.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 +241 -0
- package/android/build.gradle +35 -1
- package/android/src/main/cpp/CMakeLists.txt +64 -2
- package/android/src/main/cpp/stitcher_jsi_install_jni.cpp +227 -0
- package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +30 -11
- package/android/src/main/java/io/imagestitcher/rn/RNImageStitcherPackage.kt +21 -3
- package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +78 -3
- package/android/src/main/java/io/imagestitcher/rn/SaveFrameAsJpegPlugin.kt +162 -0
- package/android/src/main/java/io/imagestitcher/rn/StitcherJsiInstallerModule.kt +103 -0
- package/android/src/main/java/io/imagestitcher/rn/StitcherWorkletRuntime.kt +256 -0
- package/android/src/main/java/io/imagestitcher/rn/TransferredNV21.kt +100 -0
- package/cpp/stitcher_frame_data.hpp +141 -0
- package/cpp/stitcher_frame_jsi.cpp +214 -0
- package/cpp/stitcher_frame_jsi.hpp +108 -0
- package/cpp/stitcher_proxy_jsi.cpp +109 -0
- package/cpp/stitcher_proxy_jsi.hpp +46 -0
- package/cpp/stitcher_worklet_dispatch.cpp +103 -0
- package/cpp/stitcher_worklet_dispatch.hpp +71 -0
- package/cpp/stitcher_worklet_registry.cpp +81 -0
- package/cpp/stitcher_worklet_registry.hpp +136 -0
- package/dist/camera/Camera.d.ts +62 -12
- package/dist/camera/Camera.js +30 -15
- package/dist/index.d.ts +6 -0
- package/dist/index.js +30 -1
- package/dist/stitching/StitcherFrame.d.ts +170 -0
- package/dist/stitching/StitcherFrame.js +4 -0
- package/dist/stitching/StitcherWorkletRegistry.d.ts +117 -0
- package/dist/stitching/StitcherWorkletRegistry.js +78 -0
- package/dist/stitching/ensureStitcherProxyInstalled.d.ts +8 -0
- package/dist/stitching/ensureStitcherProxyInstalled.js +81 -0
- package/dist/stitching/useFrameProcessor.d.ts +119 -0
- package/dist/stitching/useFrameProcessor.js +196 -0
- package/dist/stitching/useFrameStream.d.ts +34 -0
- package/dist/stitching/useFrameStream.js +219 -0
- package/dist/stitching/useThrottledFrameProcessor.d.ts +33 -0
- package/dist/stitching/useThrottledFrameProcessor.js +132 -0
- package/dist/types.d.ts +87 -0
- package/ios/Sources/RNImageStitcher/RNSARSession.swift +46 -10
- package/ios/Sources/RNImageStitcher/RNSARWorkletRuntime.h +128 -0
- package/ios/Sources/RNImageStitcher/RNSARWorkletRuntime.mm +313 -0
- package/ios/Sources/RNImageStitcher/SaveFrameAsJpegPlugin.mm +185 -0
- package/ios/Sources/RNImageStitcher/StitcherFrameHostObject.h +60 -0
- package/ios/Sources/RNImageStitcher/StitcherFrameHostObject.mm +214 -0
- package/ios/Sources/RNImageStitcher/StitcherJsiInstaller.h +42 -0
- package/ios/Sources/RNImageStitcher/StitcherJsiInstaller.mm +103 -0
- package/package.json +1 -1
- package/src/camera/Camera.tsx +93 -28
- package/src/index.ts +35 -0
- package/src/stitching/StitcherFrame.ts +197 -0
- package/src/stitching/StitcherWorkletRegistry.ts +156 -0
- package/src/stitching/__tests__/StitcherWorkletRegistry.test.ts +176 -0
- package/src/stitching/__tests__/ensureStitcherProxyInstalled.test.ts +94 -0
- package/src/stitching/__tests__/useThrottledFrameProcessor.test.ts +178 -0
- package/src/stitching/ensureStitcherProxyInstalled.ts +141 -0
- package/src/stitching/useFrameProcessor.ts +226 -0
- package/src/stitching/useFrameStream.ts +255 -0
- package/src/stitching/useThrottledFrameProcessor.ts +145 -0
- package/src/types.ts +95 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* v0.8.0 — unified frame contract for the lib's worklet processor.
|
|
5
|
+
*
|
|
6
|
+
* Worklets registered via the v0.8.0 `useFrameProcessor` hook (also in
|
|
7
|
+
* this directory) receive a `StitcherFrame` regardless of capture mode.
|
|
8
|
+
* The lib-owned worklet runtime guarantees the same JS-visible shape
|
|
9
|
+
* whether the underlying source is a vision-camera `Frame` (non-AR
|
|
10
|
+
* mode, sourced from the FP plugin) or an ARKit `ARFrame` / ARCore
|
|
11
|
+
* `Frame` (AR mode, sourced from a lib-managed delegate that the AR
|
|
12
|
+
* worklet runtime drives).
|
|
13
|
+
*
|
|
14
|
+
* ## Why structural (NOT `extends Frame`)
|
|
15
|
+
*
|
|
16
|
+
* vision-camera's iOS `Frame` is `CMSampleBufferRef`-shaped; ARFrame's
|
|
17
|
+
* `capturedImage` (a `CVPixelBufferRef`) can be wrapped into one
|
|
18
|
+
* (Phase-0 audit confirmed the iOS path). But vision-camera's
|
|
19
|
+
* **Android** `Frame` is `androidx.camera.core.ImageProxy`-coupled —
|
|
20
|
+
* ARCore does NOT produce `ImageProxy` instances. Forcing
|
|
21
|
+
* `StitcherFrame extends Frame` would either (a) require reverse-
|
|
22
|
+
* engineering ImageProxy on Android (intractable + fragile), or
|
|
23
|
+
* (b) make the type asymmetric per platform. Both are worse than
|
|
24
|
+
* making `StitcherFrame` a structural sibling type that vc Frames
|
|
25
|
+
* happen to satisfy (because vc Frames carry the same width / height /
|
|
26
|
+
* orientation / pixelFormat / timestamp / toArrayBuffer surface).
|
|
27
|
+
*
|
|
28
|
+
* The `__source: 'vc' | 'ar'` discriminator lets worklets gate on
|
|
29
|
+
* mode without a typeof / try-catch dance — e.g., skip work that
|
|
30
|
+
* needs AR tracking state when the source is `'vc'`.
|
|
31
|
+
*
|
|
32
|
+
* ## Buffer lifetime
|
|
33
|
+
*
|
|
34
|
+
* The underlying camera buffer (CMSampleBufferRef / ImageProxy /
|
|
35
|
+
* ARFrame.capturedImage) is valid only for the duration of the worklet
|
|
36
|
+
* call. Worklets that need to retain frame data MUST copy
|
|
37
|
+
* synchronously inside the worklet body (via `toArrayBuffer()` or via
|
|
38
|
+
* a JPEG-encode frame-processor plugin). Returning a reference and
|
|
39
|
+
* reading it later will read into freed memory.
|
|
40
|
+
*/
|
|
41
|
+
export interface StitcherFrame {
|
|
42
|
+
// ── vision-camera-shaped fields (structural compat) ─────────────
|
|
43
|
+
// Worklets written against a vc `Frame` work unchanged against a
|
|
44
|
+
// `StitcherFrame` (the fields below are a strict subset of vc
|
|
45
|
+
// Frame's JS-visible surface).
|
|
46
|
+
|
|
47
|
+
/** Pixel width of the camera image. */
|
|
48
|
+
width: number;
|
|
49
|
+
|
|
50
|
+
/** Pixel height of the camera image. */
|
|
51
|
+
height: number;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Pixel format identifier. Both modes today emit `'yuv'` (NV12 on
|
|
55
|
+
* iOS, NV21 on Android). Other vision-camera formats may appear
|
|
56
|
+
* in future releases.
|
|
57
|
+
*
|
|
58
|
+
* **`'unknown'` semantics:** the lib reached a code path that
|
|
59
|
+
* doesn't recognise the underlying camera buffer's pixel format
|
|
60
|
+
* (e.g., a future ARKit version emits BGRA when historically it
|
|
61
|
+
* only emitted NV12). Worklets that depend on a known layout
|
|
62
|
+
* should treat `'unknown'` as "skip this frame". `toArrayBuffer()`
|
|
63
|
+
* still returns bytes when the format is `'unknown'`, but the
|
|
64
|
+
* layout is undefined — the bytes are the underlying buffer's
|
|
65
|
+
* first plane and may not be interpretable. When this happens
|
|
66
|
+
* the native side also emits an `os_log` / logcat warning.
|
|
67
|
+
*/
|
|
68
|
+
pixelFormat: 'yuv' | 'rgb' | 'unknown';
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Display orientation tag, matching vision-camera's
|
|
72
|
+
* `Frame.orientation`.
|
|
73
|
+
*
|
|
74
|
+
* **AR-mode limitation (v0.8.0):** AR-source frames return only
|
|
75
|
+
* the coarse two-value set `'landscape-right' | 'portrait'` (the
|
|
76
|
+
* lib reads `pose.imageWidth >= pose.imageHeight` as the
|
|
77
|
+
* discriminator since ARKit's `capturedImage` is always in the
|
|
78
|
+
* camera's native landscape-right orientation regardless of
|
|
79
|
+
* device pose). Worklets that need to distinguish
|
|
80
|
+
* `landscape-left` (upside-down landscape) or
|
|
81
|
+
* `portrait-upside-down` should consult device-orientation sensors
|
|
82
|
+
* separately while running in AR mode. Non-AR frames (vc source)
|
|
83
|
+
* return the full four-value set. Fixing the AR side requires
|
|
84
|
+
* threading `UIDevice.current.orientation` through; deferred to
|
|
85
|
+
* v0.8.1+ unless a consumer hits it.
|
|
86
|
+
*/
|
|
87
|
+
orientation:
|
|
88
|
+
| 'portrait'
|
|
89
|
+
| 'portrait-upside-down'
|
|
90
|
+
| 'landscape-left'
|
|
91
|
+
| 'landscape-right';
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Monotonic timestamp in **nanoseconds** (matches vision-camera's
|
|
95
|
+
* `Frame.timestamp` convention). Use timestamp deltas for
|
|
96
|
+
* inter-frame timing; the absolute value is implementation-defined
|
|
97
|
+
* and not comparable to `Date.now()`.
|
|
98
|
+
*/
|
|
99
|
+
timestamp: number;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Copies the underlying pixel buffer into a JSI `ArrayBuffer`.
|
|
103
|
+
* Worklet-callable. Allocates O(width × height × bytesPerPixel)
|
|
104
|
+
* each call — avoid in tight inner loops; prefer plugin-side
|
|
105
|
+
* processing where possible.
|
|
106
|
+
*/
|
|
107
|
+
toArrayBuffer(): ArrayBuffer;
|
|
108
|
+
|
|
109
|
+
// ── Lib additions ─────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Camera pose at frame-capture time. Always present.
|
|
113
|
+
*
|
|
114
|
+
* Rotation quaternion order is `(x, y, z, w)`; the lib uses
|
|
115
|
+
* `q = q_yaw * q_pitch * q_roll` throughout the engine + sensor
|
|
116
|
+
* fusion. Same convention surfaced by the v0.7.0
|
|
117
|
+
* `AcceptedKeyframe.pose` field.
|
|
118
|
+
*
|
|
119
|
+
* Translation is metres in world coordinates. Populated by AR
|
|
120
|
+
* mode (real ARKit / ARCore camera transform); undefined in
|
|
121
|
+
* non-AR mode (gyro provides only rotation — no spatial anchor).
|
|
122
|
+
*/
|
|
123
|
+
pose: {
|
|
124
|
+
rotation: [number, number, number, number];
|
|
125
|
+
translation?: [number, number, number];
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Discriminator for the frame source. Worklets branch on this to
|
|
130
|
+
* gate AR-only field access without try/catch. Standard TS
|
|
131
|
+
* discriminated-union pattern.
|
|
132
|
+
*
|
|
133
|
+
* - `'vc'` — vision-camera Frame Processor (non-AR mode)
|
|
134
|
+
* - `'ar'` — AR-session frame (AR mode); `arDepth` / `arAnchors` /
|
|
135
|
+
* `arTrackingState` fields may be populated
|
|
136
|
+
*/
|
|
137
|
+
source: 'vc' | 'ar';
|
|
138
|
+
|
|
139
|
+
// ── AR-only optional fields ───────────────────────────────────
|
|
140
|
+
// Always undefined in `__source === 'vc'` mode.
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Depth data when available — AR mode + a device that supports
|
|
144
|
+
* the AR framework's depth API (iPhone Pro LiDAR; ARCore Depth
|
|
145
|
+
* API on supported Android devices).
|
|
146
|
+
*
|
|
147
|
+
* Resolution is typically lower than the camera image (e.g.,
|
|
148
|
+
* 256×192 on iPhone Pro LiDAR). `confidenceMap` is per-pixel:
|
|
149
|
+
* `0` = low, `1` = medium, `2` = high confidence. `Float32`
|
|
150
|
+
* depth in metres; `Uint8` confidence.
|
|
151
|
+
*/
|
|
152
|
+
arDepth?: {
|
|
153
|
+
width: number;
|
|
154
|
+
height: number;
|
|
155
|
+
depthMap: ArrayBuffer;
|
|
156
|
+
confidenceMap?: ArrayBuffer;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Tracked AR anchors visible in this frame. Empty array if AR
|
|
161
|
+
* is active but no anchors are tracked. Undefined in non-AR mode.
|
|
162
|
+
*/
|
|
163
|
+
arAnchors?: ARAnchor[];
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* AR tracking quality. Worklets that should skip work when
|
|
167
|
+
* tracking is degraded check this. Undefined in non-AR mode.
|
|
168
|
+
*/
|
|
169
|
+
arTrackingState?: 'notAvailable' | 'limited' | 'normal';
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* v0.8.0 — public AR anchor type. Subset of ARKit/ARCore anchor info
|
|
174
|
+
* exposed to JS worklets. Extend with plane-extent / image-name
|
|
175
|
+
* fields as the JSI binding learns them.
|
|
176
|
+
*/
|
|
177
|
+
export interface ARAnchor {
|
|
178
|
+
/** Stable per-session anchor identifier. */
|
|
179
|
+
id: string;
|
|
180
|
+
/** Anchor kind. `'point'` is Android (ARCore) only. */
|
|
181
|
+
type: 'plane' | 'image' | 'point';
|
|
182
|
+
/**
|
|
183
|
+
* 4×4 row-major transform from anchor space to world space.
|
|
184
|
+
* 16 numbers.
|
|
185
|
+
*/
|
|
186
|
+
transform: number[];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* v0.8.0 — worklet function signature for the unified frame processor.
|
|
191
|
+
*
|
|
192
|
+
* Must be a `'worklet'`-prefixed function (so it can run on the
|
|
193
|
+
* worklet runtime). Receives a `StitcherFrame` per camera frame; the
|
|
194
|
+
* return value is ignored (use `runOnJS` / shared values to surface
|
|
195
|
+
* results back to the JS thread).
|
|
196
|
+
*/
|
|
197
|
+
export type StitcherFrameProcessor = (frame: StitcherFrame) => void;
|
|
@@ -0,0 +1,156 @@
|
|
|
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();
|
|
@@ -0,0 +1,176 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
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
|
+
});
|