react-native-image-stitcher 0.7.0 → 0.8.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 +180 -1
- 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 +4 -0
- package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +78 -3
- 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 +2 -0
- package/dist/index.js +11 -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/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/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 +16 -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/ensureStitcherProxyInstalled.ts +141 -0
- package/src/stitching/useFrameProcessor.ts +226 -0
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
import { NativeModules } from 'react-native';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* v0.8.0 Phase 4b — one-shot installer that asks the native side
|
|
7
|
+
* to install `globalThis.__stitcherProxy` on the main JS runtime.
|
|
8
|
+
*
|
|
9
|
+
* ## When this runs
|
|
10
|
+
*
|
|
11
|
+
* The first call to `useFrameProcessor` triggers this. Idempotent:
|
|
12
|
+
* once the global is installed, subsequent calls short-circuit.
|
|
13
|
+
*
|
|
14
|
+
* ## What it does
|
|
15
|
+
*
|
|
16
|
+
* Calls into the platform-native `StitcherJsiInstaller` RN module
|
|
17
|
+
* which is registered with a `RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(install)`
|
|
18
|
+
* on iOS (see `ios/Sources/RNImageStitcher/StitcherJsiInstaller.mm`)
|
|
19
|
+
* and — Phase 4b.ii — an analogous Kotlin TurboModule on Android.
|
|
20
|
+
*
|
|
21
|
+
* The native module reaches into the main JS runtime via
|
|
22
|
+
* `RCTCxxBridge.runtime` (iOS) / the equivalent Android JSI access
|
|
23
|
+
* pattern and installs a host object on `globalThis.__stitcherProxy`
|
|
24
|
+
* exposing `install(workletFn)` / `uninstall(id)` / `count()`.
|
|
25
|
+
*
|
|
26
|
+
* ## Failure modes (and what happens then)
|
|
27
|
+
*
|
|
28
|
+
* 1. **Module not registered** (Android in Phase 4b.i; old iOS
|
|
29
|
+
* builds without the new pod files). `NativeModules
|
|
30
|
+
* .StitcherJsiInstaller` is `undefined`. This function returns
|
|
31
|
+
* `false` and the hook falls back to the JS-side
|
|
32
|
+
* `StitcherWorkletRegistry` — host worklets are registered
|
|
33
|
+
* on the JS side but never fan out to AR mode. No crash, no
|
|
34
|
+
* regression vs. Phase 4a.
|
|
35
|
+
*
|
|
36
|
+
* 2. **JSI runtime unreachable** (e.g., remote debug mode). The
|
|
37
|
+
* sync method returns `false`. Same JS-side-registry fallback.
|
|
38
|
+
*
|
|
39
|
+
* 3. **Native install succeeds but global not yet visible.**
|
|
40
|
+
* The native call is SYNCHRONOUS (`BLOCKING_SYNCHRONOUS_METHOD`),
|
|
41
|
+
* so by the time the function returns the global is installed.
|
|
42
|
+
* No race here.
|
|
43
|
+
*
|
|
44
|
+
* ## Why a separate module
|
|
45
|
+
*
|
|
46
|
+
* The install method is a one-time runtime bootstrap, not a
|
|
47
|
+
* per-call API. Putting it on its own RN module (vs. on the
|
|
48
|
+
* existing `StitcherBridge` / `IncrementalStitcherBridge`) keeps
|
|
49
|
+
* the responsibility surface narrow and the failure mode easy
|
|
50
|
+
* to diagnose ("`__stitcherProxy` not installed" → check
|
|
51
|
+
* `StitcherJsiInstaller` module registration first).
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
interface StitcherJsiInstallerModule {
|
|
55
|
+
install(): boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* `__DEV__` is RN's global dev-flag. Guard the read with `typeof`
|
|
60
|
+
* so the helper works in any environment that imports it without
|
|
61
|
+
* defining __DEV__ (jest, SSR, custom tooling). Same pattern RN's
|
|
62
|
+
* own debug code uses.
|
|
63
|
+
*/
|
|
64
|
+
function isDev(): boolean {
|
|
65
|
+
return typeof __DEV__ !== 'undefined' && __DEV__;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let installed = false;
|
|
69
|
+
|
|
70
|
+
export function ensureStitcherProxyInstalled(): boolean {
|
|
71
|
+
if (installed) return true;
|
|
72
|
+
// Already installed by an earlier hook mount. Cheap fast-path.
|
|
73
|
+
if (typeof (globalThis as { __stitcherProxy?: unknown }).__stitcherProxy !== 'undefined') {
|
|
74
|
+
installed = true;
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const mod = (NativeModules as { StitcherJsiInstaller?: StitcherJsiInstallerModule })
|
|
79
|
+
.StitcherJsiInstaller;
|
|
80
|
+
if (mod == null || typeof mod.install !== 'function') {
|
|
81
|
+
// Module not present — Android until Phase 4b.ii lands, or
|
|
82
|
+
// an old iOS build. Surface this once at debug-info level so
|
|
83
|
+
// the host can see "your worklets are JS-registered only" in
|
|
84
|
+
// logcat / Console.app without a noisy per-frame warning.
|
|
85
|
+
if (isDev() && !warnedAboutMissingModule) {
|
|
86
|
+
warnedAboutMissingModule = true;
|
|
87
|
+
console.info(
|
|
88
|
+
'[react-native-image-stitcher] StitcherJsiInstaller native ' +
|
|
89
|
+
'module not found; host worklets registered in JS-side ' +
|
|
90
|
+
'registry only. AR-mode dispatch requires the native install ' +
|
|
91
|
+
'(iOS Phase 4b.i — included in v0.8.0; Android Phase 4b.ii ' +
|
|
92
|
+
'— follow-up release).',
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
const ok = mod.install();
|
|
100
|
+
if (!ok) {
|
|
101
|
+
// Native module ran but couldn't install (JSI runtime
|
|
102
|
+
// unreachable). Same fallback as the missing-module case.
|
|
103
|
+
if (isDev() && !warnedAboutFailedInstall) {
|
|
104
|
+
warnedAboutFailedInstall = true;
|
|
105
|
+
console.info(
|
|
106
|
+
'[react-native-image-stitcher] StitcherJsiInstaller.install() ' +
|
|
107
|
+
'returned false (JSI runtime unreachable — remote debug ' +
|
|
108
|
+
'mode?). Falling back to JS-side host worklet registry.',
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
installed = true;
|
|
114
|
+
return true;
|
|
115
|
+
} catch (err) {
|
|
116
|
+
if (isDev() && !warnedAboutFailedInstall) {
|
|
117
|
+
warnedAboutFailedInstall = true;
|
|
118
|
+
console.info(
|
|
119
|
+
'[react-native-image-stitcher] StitcherJsiInstaller.install() ' +
|
|
120
|
+
'threw: ' +
|
|
121
|
+
String(err) +
|
|
122
|
+
'. Falling back to JS-side host worklet registry.',
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
let warnedAboutMissingModule = false;
|
|
130
|
+
let warnedAboutFailedInstall = false;
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Test-only — reset module-internal state. Used by jest to allow
|
|
134
|
+
* multiple test cases to re-trigger the install path independently.
|
|
135
|
+
* NOT exported from `src/index.ts`.
|
|
136
|
+
*/
|
|
137
|
+
export function _resetStitcherProxyInstallStateForTests(): void {
|
|
138
|
+
installed = false;
|
|
139
|
+
warnedAboutMissingModule = false;
|
|
140
|
+
warnedAboutFailedInstall = false;
|
|
141
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
import { useEffect, type DependencyList } from 'react';
|
|
4
|
+
import {
|
|
5
|
+
useFrameProcessor as visionCameraUseFrameProcessor,
|
|
6
|
+
type DrawableFrameProcessor,
|
|
7
|
+
type Frame,
|
|
8
|
+
type ReadonlyFrameProcessor,
|
|
9
|
+
} from 'react-native-vision-camera';
|
|
10
|
+
|
|
11
|
+
import { ensureStitcherProxyInstalled } from './ensureStitcherProxyInstalled';
|
|
12
|
+
import { StitcherWorkletRegistry } from './StitcherWorkletRegistry';
|
|
13
|
+
import type { StitcherFrameProcessor } from './StitcherFrame';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Shape of the native-installed `globalThis.__stitcherProxy` host
|
|
17
|
+
* object (iOS Phase 4b.i; Android Phase 4b.ii). When present, the
|
|
18
|
+
* hook prefers the native registry over the JS-side mirror — the
|
|
19
|
+
* native AR worklet runtime reads from the native side directly.
|
|
20
|
+
*/
|
|
21
|
+
interface StitcherProxy {
|
|
22
|
+
install(workletFn: StitcherFrameProcessor): string;
|
|
23
|
+
uninstall(id: string): void;
|
|
24
|
+
count(): number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* v0.8.0 Phase 4a — public hook for hosts to attach a per-frame
|
|
29
|
+
* worklet that runs in BOTH AR and non-AR capture modes.
|
|
30
|
+
*
|
|
31
|
+
* ## Quick start
|
|
32
|
+
*
|
|
33
|
+
* ```tsx
|
|
34
|
+
* import { useFrameProcessor, type StitcherFrame } from 'react-native-image-stitcher';
|
|
35
|
+
*
|
|
36
|
+
* function MyOcrOverlay() {
|
|
37
|
+
* const processor = useFrameProcessor((frame: StitcherFrame) => {
|
|
38
|
+
* 'worklet';
|
|
39
|
+
* // Pixel data is in `frame.toArrayBuffer()`.
|
|
40
|
+
* // AR-only fields: `frame.arDepth`, `frame.arAnchors`, `frame.arTrackingState`.
|
|
41
|
+
* // Discriminate via `frame.source === 'ar'` / `'vc'`.
|
|
42
|
+
* }, []);
|
|
43
|
+
* return <Camera frameProcessor={processor} ... />;
|
|
44
|
+
* }
|
|
45
|
+
* ```
|
|
46
|
+
*
|
|
47
|
+
* ## Two behaviours, depending on mode
|
|
48
|
+
*
|
|
49
|
+
* **Non-AR mode (today, fully working):** the worklet runs on
|
|
50
|
+
* vision-camera's Frame Processor runtime. Same thread + same
|
|
51
|
+
* cost envelope as a plain `useFrameProcessor` from
|
|
52
|
+
* `react-native-vision-camera`. The lib's own first-party
|
|
53
|
+
* stitching plugin runs alongside on the same producer-thread
|
|
54
|
+
* runtime (composition is handled by vision-camera's own dispatch
|
|
55
|
+
* order).
|
|
56
|
+
*
|
|
57
|
+
* Your worklet receives whatever vision-camera delivers — vc's raw
|
|
58
|
+
* `Frame`. This is a structural subset of `StitcherFrame`: the
|
|
59
|
+
* vc-shaped fields (`width`, `height`, `pixelFormat`, `orientation`,
|
|
60
|
+
* `timestamp`, `toArrayBuffer`) are guaranteed; the
|
|
61
|
+
* `StitcherFrame`-only fields (`source`, `pose`, `arDepth`,
|
|
62
|
+
* `arAnchors`, `arTrackingState`) are **undefined** at runtime
|
|
63
|
+
* because the lib does NOT wrap or augment vc's `Frame` in Phase 4a
|
|
64
|
+
* (cross-worklet-boundary field injection is Phase 4b work).
|
|
65
|
+
* Worklets that need to read `source` / `pose` MUST guard for
|
|
66
|
+
* `undefined`:
|
|
67
|
+
*
|
|
68
|
+
* ```ts
|
|
69
|
+
* if (frame.source === 'ar') { ... } // false in non-AR mode
|
|
70
|
+
* if (frame.pose) { ... } // skipped in non-AR mode
|
|
71
|
+
* ```
|
|
72
|
+
*
|
|
73
|
+
* **AR mode — iOS Phase 4b.i (this release):** the worklet is
|
|
74
|
+
* installed into the native registry via
|
|
75
|
+
* `globalThis.__stitcherProxy.install(workletFn)`, where
|
|
76
|
+
* `__stitcherProxy` is a JSI host object installed at lib
|
|
77
|
+
* bootstrap by the native `StitcherJsiInstaller` module. The
|
|
78
|
+
* AR worklet runtime (`RNSARWorkletRuntime`) reads from the
|
|
79
|
+
* native registry on each `dispatchFrame:pose:` call and fans
|
|
80
|
+
* out invocations — your worklet fires alongside the lib's
|
|
81
|
+
* first-party stitching path.
|
|
82
|
+
*
|
|
83
|
+
* **AR mode — Android Phase 4b.ii (deferred):** the native
|
|
84
|
+
* installer + JNI bridge from `StitcherWorkletRuntime.kt`'s
|
|
85
|
+
* `runFirstParty {...}` path to a parallel C++ registry land in
|
|
86
|
+
* a follow-up release. Until then, on Android the hook falls
|
|
87
|
+
* back to the JS-side `StitcherWorkletRegistry`; AR-mode host
|
|
88
|
+
* worklets register but do not invoke. No regression vs.
|
|
89
|
+
* Phase 4a; iOS gets the API first.
|
|
90
|
+
*
|
|
91
|
+
* ### When Phase 4b.ii lands (Android)
|
|
92
|
+
*
|
|
93
|
+
* The hook's call signature does NOT change. Android hosts that
|
|
94
|
+
* write code today against this API will see their worklets
|
|
95
|
+
* start firing in AR mode automatically when Phase 4b.ii is
|
|
96
|
+
* merged. No migration required.
|
|
97
|
+
*
|
|
98
|
+
* ## Frame contract
|
|
99
|
+
*
|
|
100
|
+
* The worklet receives a {@link StitcherFrame} (see
|
|
101
|
+
* `src/stitching/StitcherFrame.ts` for the full contract +
|
|
102
|
+
* lifecycle). Highlights:
|
|
103
|
+
*
|
|
104
|
+
* - **`source`** discriminator: `'vc'` or `'ar'`. Branch on this
|
|
105
|
+
* before reading `arDepth` / `arAnchors` / `arTrackingState`
|
|
106
|
+
* so non-AR captures don't break.
|
|
107
|
+
* - **`pose`** always present. `pose.translation` is `undefined`
|
|
108
|
+
* in non-AR mode (gyro provides only rotation; no spatial
|
|
109
|
+
* anchor).
|
|
110
|
+
* - **Buffer lifetime**: pixel data is valid only for the
|
|
111
|
+
* duration of the worklet call. Worklets that need to retain
|
|
112
|
+
* data must `toArrayBuffer()` synchronously inside the
|
|
113
|
+
* worklet body — returning a reference and reading it later
|
|
114
|
+
* reads freed memory.
|
|
115
|
+
*
|
|
116
|
+
* ## Threading
|
|
117
|
+
*
|
|
118
|
+
* The worklet runs on the producer thread (vision-camera's
|
|
119
|
+
* runtime in non-AR mode; the AR-session callback thread under
|
|
120
|
+
* Phase 4b). Worklets MUST NOT block the producer thread for
|
|
121
|
+
* more than a few ms — the next frame's processing is gated on
|
|
122
|
+
* the previous frame returning. Long work belongs on a queue
|
|
123
|
+
* crossed via Reanimated / worklets-core's `runOnJS`.
|
|
124
|
+
*
|
|
125
|
+
* @param worklet The host's frame processor function. Must be
|
|
126
|
+
* `'worklet'`-prefixed at the call site. TS
|
|
127
|
+
* cannot enforce the prefix; the runtime will
|
|
128
|
+
* throw at attempt to invoke a non-worklet
|
|
129
|
+
* function.
|
|
130
|
+
* @param deps Standard React deps array. When `deps` change,
|
|
131
|
+
* the previous registration is removed and the
|
|
132
|
+
* new worklet is registered. Same semantics as
|
|
133
|
+
* vision-camera's `useFrameProcessor`.
|
|
134
|
+
*
|
|
135
|
+
* @returns A vision-camera frame-processor object that
|
|
136
|
+
* `<Camera frameProcessor={...}>` accepts. In non-AR
|
|
137
|
+
* mode this is what drives the per-frame worklet
|
|
138
|
+
* invocation; in AR mode it's currently a no-op (vc
|
|
139
|
+
* isn't mounted in AR mode anyway).
|
|
140
|
+
*/
|
|
141
|
+
export function useFrameProcessor(
|
|
142
|
+
worklet: StitcherFrameProcessor,
|
|
143
|
+
deps: DependencyList,
|
|
144
|
+
): ReadonlyFrameProcessor | DrawableFrameProcessor {
|
|
145
|
+
// Non-AR path: delegate to vision-camera's hook. The returned
|
|
146
|
+
// processor object is what `<Camera>` hands to vc. Worklet
|
|
147
|
+
// fires on vc's producer-thread runtime.
|
|
148
|
+
//
|
|
149
|
+
// Cast rationale: vc's hook expects `(frame: Frame) => void`.
|
|
150
|
+
// Our worklet is typed `(frame: StitcherFrame) => void`.
|
|
151
|
+
// `StitcherFrame` is a structural superset of `Frame` (it adds
|
|
152
|
+
// required `source` + `pose` and the optional AR fields), so
|
|
153
|
+
// assigning a function that consumes `StitcherFrame` to a
|
|
154
|
+
// `Frame`-consuming slot is unsound at the type level — TS is
|
|
155
|
+
// right to reject the direct assignment. At RUNTIME the worklet
|
|
156
|
+
// will see vc's raw `Frame`; the `source` / `pose` / AR fields
|
|
157
|
+
// are undefined (the hook's docstring above documents this and
|
|
158
|
+
// tells hosts to guard). We double-cast through `unknown` to
|
|
159
|
+
// suppress, accepting the explicit type-system gap as the price
|
|
160
|
+
// of Phase 4a's pre-Phase-4b deferral on cross-runtime frame
|
|
161
|
+
// wrapping.
|
|
162
|
+
const vcProcessor = visionCameraUseFrameProcessor(
|
|
163
|
+
worklet as unknown as (frame: Frame) => void,
|
|
164
|
+
deps,
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
// AR path: install into the native registry if available (iOS
|
|
168
|
+
// Phase 4b.i — and Android Phase 4b.ii once it lands). Falls
|
|
169
|
+
// back to the JS-side `StitcherWorkletRegistry` when the native
|
|
170
|
+
// installer isn't present (Android in 4b.i; remote debug mode;
|
|
171
|
+
// unit tests). The fallback path matches Phase 4a's
|
|
172
|
+
// register-but-not-invoke semantics.
|
|
173
|
+
//
|
|
174
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
175
|
+
useEffect(() => {
|
|
176
|
+
const nativeReady = ensureStitcherProxyInstalled();
|
|
177
|
+
if (
|
|
178
|
+
nativeReady &&
|
|
179
|
+
typeof (globalThis as { __stitcherProxy?: StitcherProxy }).__stitcherProxy !== 'undefined'
|
|
180
|
+
) {
|
|
181
|
+
// Native path — install through the JSI proxy. Errors here
|
|
182
|
+
// most commonly mean the worklet doesn't have the
|
|
183
|
+
// `'worklet'` directive at the call site (the worklets-core
|
|
184
|
+
// babel plugin didn't transform it). Surface them via the
|
|
185
|
+
// proxy's own throw with a host-side log so the failure is
|
|
186
|
+
// obvious.
|
|
187
|
+
let id: string | undefined;
|
|
188
|
+
try {
|
|
189
|
+
id = (globalThis as unknown as { __stitcherProxy: StitcherProxy }).__stitcherProxy.install(
|
|
190
|
+
worklet,
|
|
191
|
+
);
|
|
192
|
+
} catch (err) {
|
|
193
|
+
// Guard `__DEV__` read so the hook works in any environment
|
|
194
|
+
// that imports it without defining the flag (jest, SSR,
|
|
195
|
+
// custom tooling).
|
|
196
|
+
if (typeof __DEV__ !== 'undefined' && __DEV__) {
|
|
197
|
+
console.error(
|
|
198
|
+
'[react-native-image-stitcher] __stitcherProxy.install ' +
|
|
199
|
+
'threw — is the worklet function decorated with ' +
|
|
200
|
+
"`'worklet';` and processed by react-native-worklets-core's " +
|
|
201
|
+
'babel plugin? Original error: ' +
|
|
202
|
+
String(err),
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
return; // No cleanup needed — nothing was installed.
|
|
206
|
+
}
|
|
207
|
+
return () => {
|
|
208
|
+
try {
|
|
209
|
+
(globalThis as unknown as { __stitcherProxy: StitcherProxy }).__stitcherProxy.uninstall(id!);
|
|
210
|
+
} catch {
|
|
211
|
+
// Uninstall is best-effort; an exception here means the
|
|
212
|
+
// proxy was already gone (e.g., app reload mid-cleanup).
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Fallback — JS-side registry. Same as Phase 4a.
|
|
218
|
+
const jsId = StitcherWorkletRegistry.register({
|
|
219
|
+
worklet,
|
|
220
|
+
isFirstParty: false,
|
|
221
|
+
});
|
|
222
|
+
return () => StitcherWorkletRegistry.unregister(jsId);
|
|
223
|
+
}, deps);
|
|
224
|
+
|
|
225
|
+
return vcProcessor;
|
|
226
|
+
}
|