react-native-image-stitcher 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +269 -0
- package/android/build.gradle +10 -0
- package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +115 -10
- package/android/src/main/java/io/imagestitcher/rn/RNImageStitcherPackage.kt +17 -3
- package/android/src/main/java/io/imagestitcher/rn/SaveFrameAsJpegPlugin.kt +162 -0
- package/cpp/stitcher_worklet_registry.cpp +10 -0
- package/cpp/stitcher_worklet_registry.hpp +10 -0
- package/cpp/tests/CMakeLists.txt +98 -0
- package/cpp/tests/README.md +86 -0
- package/cpp/tests/pose_test.cpp +74 -0
- package/cpp/tests/stitcher_frame_data_test.cpp +132 -0
- package/cpp/tests/stitcher_worklet_registry_test.cpp +195 -0
- package/cpp/tests/stubs/jsi/jsi.h +33 -0
- package/cpp/tests/stubs/react-native-worklets-core/WKTJsiWorklet.h +34 -0
- package/dist/camera/useCapture.d.ts +1 -1
- package/dist/camera/useCapture.js +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.js +20 -1
- package/dist/stitching/incremental.d.ts +41 -0
- package/dist/stitching/useFrameStream.d.ts +34 -0
- package/dist/stitching/useFrameStream.js +234 -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/IncrementalStitcher.swift +138 -9
- package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.swift +50 -14
- package/ios/Sources/RNImageStitcher/SaveFrameAsJpegPlugin.mm +185 -0
- package/package.json +1 -1
- package/src/camera/useCapture.ts +1 -1
- package/src/index.ts +19 -0
- package/src/stitching/__tests__/subscribeIncrementalState.refine.test.ts +276 -0
- package/src/stitching/__tests__/useThrottledFrameProcessor.test.ts +178 -0
- package/src/stitching/incremental.ts +42 -0
- package/src/stitching/useFrameStream.ts +271 -0
- package/src/stitching/useThrottledFrameProcessor.ts +145 -0
- package/src/types.ts +95 -0
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Unit + integration coverage for the v0.10.0 PR B refine-progress
|
|
4
|
+
* lifecycle on the `IncrementalStateUpdate` channel.
|
|
5
|
+
*
|
|
6
|
+
* What this test is for:
|
|
7
|
+
*
|
|
8
|
+
* - Contract: native emits a 4-stage sequence
|
|
9
|
+
* `validating → stitching → writing → done` (and the failure
|
|
10
|
+
* variant `validating → error`) with `refineStage` /
|
|
11
|
+
* `refineProgress` / `refineFrames` / `refineError` keys.
|
|
12
|
+
* - Regression: catches a future renamer of any of those keys
|
|
13
|
+
* (subscribeIncrementalState would silently deliver `undefined`
|
|
14
|
+
* for the missing fields, and the host's progress pill would
|
|
15
|
+
* stop rendering — exactly the bug class we hit on iOS in PR B
|
|
16
|
+
* before the bridgeless-interop fix).
|
|
17
|
+
*
|
|
18
|
+
* What this test is NOT for:
|
|
19
|
+
*
|
|
20
|
+
* - Exercising the real native bridge — RCTEventEmitter under RN
|
|
21
|
+
* bridgeless interop can only be tested on-device. The bug we
|
|
22
|
+
* fixed in PR B (sendEvent silently no-ops for certain body
|
|
23
|
+
* shapes) is verified via the manual smoke test recorded in
|
|
24
|
+
* CHANGELOG.md "Fixed — v0.10.0 PR B (iOS)". This file pins
|
|
25
|
+
* the JS-side contract that bridge fix has to satisfy.
|
|
26
|
+
*
|
|
27
|
+
* Mock surface: per-test `jest.mock('react-native', ...)` so the
|
|
28
|
+
* shared `jest.mocks/react-native.js` stays minimal (per the comment
|
|
29
|
+
* in that file). We stub `NativeModules.IncrementalStitcher` and
|
|
30
|
+
* `NativeEventEmitter` together because `subscribeIncrementalState`
|
|
31
|
+
* wires them together internally.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import type { IncrementalState } from '../incremental';
|
|
35
|
+
|
|
36
|
+
// Hand-rolled event-emitter fake we can drive synchronously from
|
|
37
|
+
// tests. Modelled on RN's NativeEventEmitter shape: addListener
|
|
38
|
+
// returns an object with a `.remove()` method.
|
|
39
|
+
type Listener = (state: IncrementalState) => void;
|
|
40
|
+
|
|
41
|
+
class FakeNativeEventEmitter {
|
|
42
|
+
private listeners: Map<string, Set<Listener>> = new Map();
|
|
43
|
+
|
|
44
|
+
constructor(_nativeModule: unknown) {
|
|
45
|
+
// No-op: real RN reads addListener/removeListeners off the
|
|
46
|
+
// native module for the listener-count contract; we don't.
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
addListener(eventType: string, listener: Listener) {
|
|
50
|
+
let set = this.listeners.get(eventType);
|
|
51
|
+
if (!set) {
|
|
52
|
+
set = new Set();
|
|
53
|
+
this.listeners.set(eventType, set);
|
|
54
|
+
}
|
|
55
|
+
set.add(listener);
|
|
56
|
+
return {
|
|
57
|
+
remove: () => {
|
|
58
|
+
set!.delete(listener);
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Test-only helper: drive an event into all subscribers.
|
|
64
|
+
_emit(eventType: string, state: IncrementalState) {
|
|
65
|
+
const set = this.listeners.get(eventType);
|
|
66
|
+
if (!set) return;
|
|
67
|
+
for (const listener of set) {
|
|
68
|
+
listener(state);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Shared emitter handle the per-test setup writes its asserts
|
|
74
|
+
// against. The mock factory below has to construct via `new`, so we
|
|
75
|
+
// stash the latest instance here for the test to drive.
|
|
76
|
+
let lastEmitter: FakeNativeEventEmitter | null = null;
|
|
77
|
+
|
|
78
|
+
jest.mock('react-native', () => ({
|
|
79
|
+
NativeModules: {
|
|
80
|
+
IncrementalStitcher: {
|
|
81
|
+
// RCTEventEmitter / NativeEventEmitter contract — RN's runtime
|
|
82
|
+
// calls these when JS subscribes / unsubscribes so the native
|
|
83
|
+
// side can track listener count. We just stub them.
|
|
84
|
+
addListener: jest.fn(),
|
|
85
|
+
removeListeners: jest.fn(),
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
NativeEventEmitter: jest.fn().mockImplementation((nativeModule: unknown) => {
|
|
89
|
+
lastEmitter = new FakeNativeEventEmitter(nativeModule);
|
|
90
|
+
return lastEmitter;
|
|
91
|
+
}),
|
|
92
|
+
Platform: { OS: 'ios', select: (spec: { ios?: unknown; default?: unknown }) => spec.ios ?? spec.default },
|
|
93
|
+
}));
|
|
94
|
+
|
|
95
|
+
// Import AFTER jest.mock so the SUT picks up the mocked module.
|
|
96
|
+
import { subscribeIncrementalState } from '../incremental';
|
|
97
|
+
|
|
98
|
+
// Build the base state shape the native side emits — matches the
|
|
99
|
+
// fields IncrementalStateObject.asDictionary() includes on iOS and
|
|
100
|
+
// Arguments.createMap() includes on Android.
|
|
101
|
+
function makeBaseState(): IncrementalState {
|
|
102
|
+
return {
|
|
103
|
+
width: 1920,
|
|
104
|
+
height: 1080,
|
|
105
|
+
acceptedCount: 3,
|
|
106
|
+
outcome: 8, // acceptedHigh
|
|
107
|
+
confidence: 0.92,
|
|
108
|
+
overlapPercent: 18.5,
|
|
109
|
+
processingMs: 0,
|
|
110
|
+
isLandscape: false,
|
|
111
|
+
paintedExtent: 1920,
|
|
112
|
+
panExtent: 1920,
|
|
113
|
+
keyframeMax: 0,
|
|
114
|
+
} as IncrementalState;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
describe('subscribeIncrementalState — refine progress lifecycle (v0.10.0 PR B)', () => {
|
|
118
|
+
beforeEach(() => {
|
|
119
|
+
lastEmitter = null;
|
|
120
|
+
jest.clearAllMocks();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('returns null when the native IncrementalStitcher module is missing', () => {
|
|
124
|
+
// Temporarily blank the module.
|
|
125
|
+
const RN = jest.requireMock('react-native') as { NativeModules: Record<string, unknown> };
|
|
126
|
+
const saved = RN.NativeModules.IncrementalStitcher;
|
|
127
|
+
RN.NativeModules.IncrementalStitcher = undefined;
|
|
128
|
+
try {
|
|
129
|
+
expect(subscribeIncrementalState(() => {})).toBeNull();
|
|
130
|
+
} finally {
|
|
131
|
+
RN.NativeModules.IncrementalStitcher = saved;
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('returns an EmitterSubscription when subscribed; remove() stops delivery', () => {
|
|
136
|
+
const events: IncrementalState[] = [];
|
|
137
|
+
const sub = subscribeIncrementalState((s) => events.push(s));
|
|
138
|
+
expect(sub).not.toBeNull();
|
|
139
|
+
expect(lastEmitter).not.toBeNull();
|
|
140
|
+
|
|
141
|
+
lastEmitter!._emit('IncrementalStateUpdate', {
|
|
142
|
+
...makeBaseState(),
|
|
143
|
+
refineStage: 'validating',
|
|
144
|
+
refineProgress: 0.05,
|
|
145
|
+
refineFrames: 3,
|
|
146
|
+
} as IncrementalState);
|
|
147
|
+
expect(events).toHaveLength(1);
|
|
148
|
+
|
|
149
|
+
sub!.remove();
|
|
150
|
+
lastEmitter!._emit('IncrementalStateUpdate', {
|
|
151
|
+
...makeBaseState(),
|
|
152
|
+
refineStage: 'done',
|
|
153
|
+
refineProgress: 1.0,
|
|
154
|
+
refineFrames: 3,
|
|
155
|
+
} as IncrementalState);
|
|
156
|
+
expect(events).toHaveLength(1); // unchanged after remove()
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('happy-path: delivers validating → stitching → writing → done in order with correct refineStage', () => {
|
|
160
|
+
const stages: Array<{ stage: string | undefined; progress: number | undefined }> = [];
|
|
161
|
+
subscribeIncrementalState((s) => {
|
|
162
|
+
stages.push({ stage: s.refineStage, progress: s.refineProgress });
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const sequence: Array<Pick<IncrementalState, 'refineStage' | 'refineProgress' | 'refineFrames'>> = [
|
|
166
|
+
{ refineStage: 'validating', refineProgress: 0.05, refineFrames: 3 },
|
|
167
|
+
{ refineStage: 'stitching', refineProgress: 0.10, refineFrames: 3 },
|
|
168
|
+
{ refineStage: 'writing', refineProgress: 0.90, refineFrames: 3 },
|
|
169
|
+
{ refineStage: 'done', refineProgress: 1.00, refineFrames: 3 },
|
|
170
|
+
];
|
|
171
|
+
for (const ev of sequence) {
|
|
172
|
+
lastEmitter!._emit('IncrementalStateUpdate', { ...makeBaseState(), ...ev } as IncrementalState);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
expect(stages).toEqual([
|
|
176
|
+
{ stage: 'validating', progress: 0.05 },
|
|
177
|
+
{ stage: 'stitching', progress: 0.10 },
|
|
178
|
+
{ stage: 'writing', progress: 0.90 },
|
|
179
|
+
{ stage: 'done', progress: 1.00 },
|
|
180
|
+
]);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('refineProgress is non-decreasing across the happy-path sequence (monotonicity contract)', () => {
|
|
184
|
+
const progresses: number[] = [];
|
|
185
|
+
subscribeIncrementalState((s) => {
|
|
186
|
+
if (s.refineProgress !== undefined) progresses.push(s.refineProgress);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
for (const p of [0.05, 0.10, 0.90, 1.00]) {
|
|
190
|
+
lastEmitter!._emit('IncrementalStateUpdate', {
|
|
191
|
+
...makeBaseState(),
|
|
192
|
+
refineStage: 'stitching', // stage is irrelevant for this assertion
|
|
193
|
+
refineProgress: p,
|
|
194
|
+
refineFrames: 3,
|
|
195
|
+
} as IncrementalState);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
expect(progresses).toEqual([0.05, 0.10, 0.90, 1.00]);
|
|
199
|
+
for (let i = 1; i < progresses.length; i++) {
|
|
200
|
+
expect(progresses[i]).toBeGreaterThanOrEqual(progresses[i - 1]);
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('failure-path: validating → error carries refineError; no further stages emitted', () => {
|
|
205
|
+
const events: IncrementalState[] = [];
|
|
206
|
+
subscribeIncrementalState((s) => events.push(s));
|
|
207
|
+
|
|
208
|
+
lastEmitter!._emit('IncrementalStateUpdate', {
|
|
209
|
+
...makeBaseState(),
|
|
210
|
+
refineStage: 'validating',
|
|
211
|
+
refineProgress: 0.05,
|
|
212
|
+
refineFrames: 3,
|
|
213
|
+
} as IncrementalState);
|
|
214
|
+
lastEmitter!._emit('IncrementalStateUpdate', {
|
|
215
|
+
...makeBaseState(),
|
|
216
|
+
refineStage: 'error',
|
|
217
|
+
refineProgress: 1.0,
|
|
218
|
+
refineFrames: 3,
|
|
219
|
+
refineError: 'INVALID_FRAMES: missing JPEG at index 1',
|
|
220
|
+
} as IncrementalState);
|
|
221
|
+
|
|
222
|
+
expect(events).toHaveLength(2);
|
|
223
|
+
expect(events[0].refineStage).toBe('validating');
|
|
224
|
+
expect(events[1].refineStage).toBe('error');
|
|
225
|
+
expect(events[1].refineError).toBe('INVALID_FRAMES: missing JPEG at index 1');
|
|
226
|
+
// refineError is absent on the validating event.
|
|
227
|
+
expect(events[0].refineError).toBeUndefined();
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('refineFrames passes through unchanged (regression guard for key rename)', () => {
|
|
231
|
+
const seen: Array<number | undefined> = [];
|
|
232
|
+
subscribeIncrementalState((s) => seen.push(s.refineFrames));
|
|
233
|
+
|
|
234
|
+
for (const n of [3, 5, 8]) {
|
|
235
|
+
lastEmitter!._emit('IncrementalStateUpdate', {
|
|
236
|
+
...makeBaseState(),
|
|
237
|
+
refineStage: 'stitching',
|
|
238
|
+
refineProgress: 0.5,
|
|
239
|
+
refineFrames: n,
|
|
240
|
+
} as IncrementalState);
|
|
241
|
+
}
|
|
242
|
+
expect(seen).toEqual([3, 5, 8]);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('live (non-refine) state events leave refine fields undefined', () => {
|
|
246
|
+
// Asserts that the contract is "refine fields are only populated
|
|
247
|
+
// during a refine call" — so the example app's `if
|
|
248
|
+
// (s.refineStage === undefined) return;` short-circuit is sound.
|
|
249
|
+
const events: IncrementalState[] = [];
|
|
250
|
+
subscribeIncrementalState((s) => events.push(s));
|
|
251
|
+
|
|
252
|
+
lastEmitter!._emit('IncrementalStateUpdate', makeBaseState());
|
|
253
|
+
expect(events).toHaveLength(1);
|
|
254
|
+
expect(events[0].refineStage).toBeUndefined();
|
|
255
|
+
expect(events[0].refineProgress).toBeUndefined();
|
|
256
|
+
expect(events[0].refineFrames).toBeUndefined();
|
|
257
|
+
expect(events[0].refineError).toBeUndefined();
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('subscribes on the correct channel name "IncrementalStateUpdate" (cross-platform contract)', () => {
|
|
261
|
+
// If anyone renames the event constant on either side, the
|
|
262
|
+
// subscriber stops receiving events. Pin the literal here.
|
|
263
|
+
let receivedOnRight = false;
|
|
264
|
+
let receivedOnWrong = false;
|
|
265
|
+
subscribeIncrementalState(() => {
|
|
266
|
+
receivedOnRight = true;
|
|
267
|
+
});
|
|
268
|
+
// Fire on a deliberately-wrong channel — should NOT deliver.
|
|
269
|
+
lastEmitter!._emit('SomeOtherChannel', makeBaseState());
|
|
270
|
+
expect(receivedOnRight).toBe(false);
|
|
271
|
+
expect(receivedOnWrong).toBe(false);
|
|
272
|
+
// Fire on the right channel — should deliver.
|
|
273
|
+
lastEmitter!._emit('IncrementalStateUpdate', makeBaseState());
|
|
274
|
+
expect(receivedOnRight).toBe(true);
|
|
275
|
+
});
|
|
276
|
+
});
|
|
@@ -0,0 +1,178 @@
|
|
|
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
|
+
});
|
|
@@ -261,6 +261,48 @@ export interface IncrementalState {
|
|
|
261
261
|
* keyframes on disk.
|
|
262
262
|
*/
|
|
263
263
|
refinedPanoramaPath?: string;
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* v0.10.0 (#15A) — current phase of an in-flight `refinePanorama`
|
|
267
|
+
* call. Fires from both the explicit `module.refinePanorama(...)`
|
|
268
|
+
* JS API path AND the hybrid-engine auto-refine path (which calls
|
|
269
|
+
* the same native refinePanorama internally).
|
|
270
|
+
*
|
|
271
|
+
* Lifecycle:
|
|
272
|
+
* - `"validating"` (fraction 0.05) — synchronous input checks
|
|
273
|
+
* - `"stitching"` (fraction 0.10) — OpenCV stitch in flight
|
|
274
|
+
* - `"writing"` (fraction 0.90) — stitch done, JPEG written
|
|
275
|
+
* - `"done"` (fraction 1.00) — success
|
|
276
|
+
* - `"error"` (fraction 1.00) — failure; `refineError` is set
|
|
277
|
+
*
|
|
278
|
+
* Coarse on purpose: OpenCV's Stitcher doesn't expose mid-pipeline
|
|
279
|
+
* progress, so the 0.10 → 0.90 jump is one opaque step. Use
|
|
280
|
+
* `refineStage` for a stage label; use `refineProgress` purely for
|
|
281
|
+
* spinner progress.
|
|
282
|
+
*
|
|
283
|
+
* Undefined when no refinement is in flight.
|
|
284
|
+
*/
|
|
285
|
+
refineStage?: 'validating' | 'stitching' | 'writing' | 'done' | 'error';
|
|
286
|
+
/**
|
|
287
|
+
* v0.10.0 (#15A) — coarse progress fraction in `[0, 1]` aligned
|
|
288
|
+
* with `refineStage`. See `refineStage` for the per-stage value
|
|
289
|
+
* mapping. Undefined when no refinement is in flight.
|
|
290
|
+
*/
|
|
291
|
+
refineProgress?: number;
|
|
292
|
+
/**
|
|
293
|
+
* v0.10.0 (#15A) — number of input frames the in-flight refine is
|
|
294
|
+
* processing. Useful for the UI label
|
|
295
|
+
* (`Stitching 6 frames…`). Mirrors the `framesRequested` field
|
|
296
|
+
* returned in the explicit refinePanorama resolution. Undefined
|
|
297
|
+
* when no refinement is in flight.
|
|
298
|
+
*/
|
|
299
|
+
refineFrames?: number;
|
|
300
|
+
/**
|
|
301
|
+
* v0.10.0 (#15A) — present only when `refineStage === 'error'`.
|
|
302
|
+
* Human-readable error message; the same text the rejected promise
|
|
303
|
+
* carries. Use to render a one-line failure pill.
|
|
304
|
+
*/
|
|
305
|
+
refineError?: string;
|
|
264
306
|
}
|
|
265
307
|
|
|
266
308
|
|