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,255 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
//
|
|
3
|
+
// v0.9.0 Layer 3 — JS-thread sampled-frame stream over Layer 1 +
|
|
4
|
+
// Layer 2.
|
|
5
|
+
//
|
|
6
|
+
// ## What this is
|
|
7
|
+
//
|
|
8
|
+
// A hook that:
|
|
9
|
+
// 1. Throttles a worklet via `useThrottledFrameProcessor` (Layer 2)
|
|
10
|
+
// to fire at `sampleHz` Hz.
|
|
11
|
+
// 2. Inside the worklet, calls the `save_frame_as_jpeg` vc Frame
|
|
12
|
+
// Processor plugin (Layer 1) to JPEG-encode the frame to a
|
|
13
|
+
// bounded-rotation slot on disk.
|
|
14
|
+
// 3. Bridges the resulting `SampledFrame` (file path + pose +
|
|
15
|
+
// dims) to a JS-thread callback via `runOnJS`.
|
|
16
|
+
//
|
|
17
|
+
// The host gets a per-sample callback on the JS thread with a file
|
|
18
|
+
// path they can pass to `<Image>`, an OCR RN module, a cloud-upload
|
|
19
|
+
// library, etc. Zero worklet boilerplate.
|
|
20
|
+
//
|
|
21
|
+
// ## When to use this (vs alternatives)
|
|
22
|
+
//
|
|
23
|
+
// - **`useFrameStream`** (this hook) — JS-thread consumers. File-
|
|
24
|
+
// path OCR libraries, cloud upload, thumbnail UI, sampled
|
|
25
|
+
// server-side analysis.
|
|
26
|
+
// - **`useThrottledFrameProcessor`** (Layer 2) — worklet-native
|
|
27
|
+
// consumers. Native OCR (Vision.framework / ML Kit) wrapped as
|
|
28
|
+
// vc plugins, TFLite ML inference, LiDAR depth processing.
|
|
29
|
+
// Lower latency; no JPEG roundtrip.
|
|
30
|
+
// - **`useFrameProcessor`** — every camera frame; full control.
|
|
31
|
+
//
|
|
32
|
+
// ## Slot reuse / disk usage
|
|
33
|
+
//
|
|
34
|
+
// JPEG files are written to `<outputDir>/stream-<N>.jpg` where N
|
|
35
|
+
// cycles 0..3 based on `frame.timestamp / 1000`. At most 4 stale
|
|
36
|
+
// JPEGs ever exist on disk; the same file is rewritten on each
|
|
37
|
+
// rotation, so disk usage is bounded.
|
|
38
|
+
//
|
|
39
|
+
// Hosts that need long-term retention (e.g., archive each sample
|
|
40
|
+
// for later upload) MUST copy the file synchronously inside the
|
|
41
|
+
// handler — the slot may be overwritten by the next sample.
|
|
42
|
+
//
|
|
43
|
+
// ## Backpressure
|
|
44
|
+
//
|
|
45
|
+
// If the JS handler returns slower than `1/sampleHz`, subsequent
|
|
46
|
+
// ticks DO still fire (the throttle is time-based, not handler-
|
|
47
|
+
// completion-based). This means multiple handler invocations can
|
|
48
|
+
// be in flight simultaneously. For most use cases that's fine
|
|
49
|
+
// (the handlers are pure or commute). Hosts that need serialised
|
|
50
|
+
// handling should track in-flight state themselves and early-return.
|
|
51
|
+
//
|
|
52
|
+
// ## AR vs non-AR
|
|
53
|
+
//
|
|
54
|
+
// Works in both modes because it composes over
|
|
55
|
+
// `useThrottledFrameProcessor` → `useFrameProcessor`. In AR mode
|
|
56
|
+
// the worklet auto-registers via `__stitcherProxy` (v0.8.0 Phase
|
|
57
|
+
// 4b.i/iii); in non-AR mode the returned processor object is
|
|
58
|
+
// passed to `<Camera frameProcessor={...}>`. The hook returns
|
|
59
|
+
// the processor object so hosts can wire it up either way.
|
|
60
|
+
|
|
61
|
+
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
|
62
|
+
import { Platform } from 'react-native';
|
|
63
|
+
import {
|
|
64
|
+
VisionCameraProxy,
|
|
65
|
+
type Frame,
|
|
66
|
+
type FrameProcessorPlugin,
|
|
67
|
+
} from 'react-native-vision-camera';
|
|
68
|
+
import { Worklets } from 'react-native-worklets-core';
|
|
69
|
+
|
|
70
|
+
import { useThrottledFrameProcessor } from './useThrottledFrameProcessor';
|
|
71
|
+
import type { StitcherFrame } from './StitcherFrame';
|
|
72
|
+
import type {
|
|
73
|
+
FrameStreamOptions,
|
|
74
|
+
SampledFrame,
|
|
75
|
+
} from '../types';
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* `useFrameStream` — Layer 3. See module docstring for the full
|
|
79
|
+
* design + use-case mapping. Quick start:
|
|
80
|
+
*
|
|
81
|
+
* ```tsx
|
|
82
|
+
* import { Camera, useFrameStream } from 'react-native-image-stitcher';
|
|
83
|
+
*
|
|
84
|
+
* function MyScreen() {
|
|
85
|
+
* const fp = useFrameStream(
|
|
86
|
+
* { sampleHz: 2, quality: 75 },
|
|
87
|
+
* (sample) => {
|
|
88
|
+
* setThumbnail(sample.jpegPath);
|
|
89
|
+
* },
|
|
90
|
+
* );
|
|
91
|
+
* return <Camera frameProcessor={fp} ... />;
|
|
92
|
+
* }
|
|
93
|
+
* ```
|
|
94
|
+
*
|
|
95
|
+
* @param options `{ sampleHz, quality?, outputDir? }`. `sampleHz`
|
|
96
|
+
* clamped to `[0.5, 10]`.
|
|
97
|
+
* @param handler JS-thread callback fired per sample. Receives a
|
|
98
|
+
* `SampledFrame`. May return a Promise; rejections
|
|
99
|
+
* are caught + logged (not re-thrown) so one
|
|
100
|
+
* misbehaving handler doesn't break the stream.
|
|
101
|
+
*
|
|
102
|
+
* @returns A `useFrameProcessor`-shaped processor object — pass to
|
|
103
|
+
* `<Camera frameProcessor={...}>` for non-AR mode wiring.
|
|
104
|
+
* (AR mode auto-registration via `__stitcherProxy` is
|
|
105
|
+
* handled inside `useFrameProcessor`.)
|
|
106
|
+
*/
|
|
107
|
+
export function useFrameStream(
|
|
108
|
+
options: FrameStreamOptions,
|
|
109
|
+
handler: (sample: SampledFrame) => void | Promise<void>,
|
|
110
|
+
): ReturnType<typeof useThrottledFrameProcessor> {
|
|
111
|
+
const sampleHz = Math.max(0.5, Math.min(10, options.sampleHz));
|
|
112
|
+
const quality = options.quality ?? 75;
|
|
113
|
+
|
|
114
|
+
// Default output dir: a per-app cache subdirectory. Hosts that
|
|
115
|
+
// want a known path supply their own via `options.outputDir`.
|
|
116
|
+
// `Platform.OS`-specific cache paths are read once at hook mount.
|
|
117
|
+
const outputDir = useMemo(() => {
|
|
118
|
+
if (options.outputDir != null) return options.outputDir;
|
|
119
|
+
// Both platforms expose a cache directory at a predictable path
|
|
120
|
+
// via React Native APIs; we use a small inline computation to
|
|
121
|
+
// avoid pulling `react-native-fs` as a hard dep. The lib's
|
|
122
|
+
// existing JPEG encode targets the app's data dir via similar
|
|
123
|
+
// logic in `RNSARCameraView.kt` / `IncrementalStitcher.swift`.
|
|
124
|
+
//
|
|
125
|
+
// We just generate a relative-ish path under /tmp/ for cross-
|
|
126
|
+
// platform simplicity; the native plugin writes wherever it's
|
|
127
|
+
// told to (absolute path), so as long as the directory exists
|
|
128
|
+
// the encode succeeds. Hosts that care about file lifecycle
|
|
129
|
+
// should supply `outputDir` explicitly.
|
|
130
|
+
return Platform.OS === 'ios'
|
|
131
|
+
? '/tmp/rnis-frame-stream'
|
|
132
|
+
: '/data/local/tmp/rnis-frame-stream';
|
|
133
|
+
}, [options.outputDir]);
|
|
134
|
+
|
|
135
|
+
// Ensure outputDir exists on the native side. We could use
|
|
136
|
+
// react-native-fs but to keep the dep surface minimal, we just
|
|
137
|
+
// attempt to create via a tiny native call — or, simpler, accept
|
|
138
|
+
// that the plugin's write call will fail if the dir doesn't
|
|
139
|
+
// exist + log a clear error. For v0.9.0 baseline we defer
|
|
140
|
+
// mkdir to the host (document it in the option's JSDoc) OR fall
|
|
141
|
+
// back to the platform's tmpdir which already exists.
|
|
142
|
+
//
|
|
143
|
+
// The tmpdir defaults above always exist on iOS + Android, so
|
|
144
|
+
// the common case "host doesn't supply outputDir" Just Works.
|
|
145
|
+
|
|
146
|
+
// Stable JS-side handler reference for `runOnJS`. The hook re-
|
|
147
|
+
// captures `handler` on every render but the ref keeps the
|
|
148
|
+
// worklet closure pointing at the latest callback (avoid stale
|
|
149
|
+
// captures).
|
|
150
|
+
const handlerRef = useRef(handler);
|
|
151
|
+
handlerRef.current = handler;
|
|
152
|
+
|
|
153
|
+
const onSampleJS = useCallback((sample: SampledFrame) => {
|
|
154
|
+
const result = handlerRef.current(sample);
|
|
155
|
+
if (
|
|
156
|
+
result != null &&
|
|
157
|
+
typeof (result as Promise<void>).catch === 'function'
|
|
158
|
+
) {
|
|
159
|
+
(result as Promise<void>).catch((err) => {
|
|
160
|
+
// eslint-disable-next-line no-console
|
|
161
|
+
console.error('[useFrameStream] handler threw:', err);
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}, []);
|
|
165
|
+
|
|
166
|
+
const onSampleOnJS = useMemo(
|
|
167
|
+
() => Worklets.createRunOnJS(onSampleJS),
|
|
168
|
+
[onSampleJS],
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
// ── Plugin acquisition (Layer 1) ─────────────────────────────────
|
|
172
|
+
//
|
|
173
|
+
// `initFrameProcessorPlugin` can return `undefined` if the native
|
|
174
|
+
// registry hasn't initialised yet (rare race on app start). We
|
|
175
|
+
// retry every 16ms (one display frame) until success — matches
|
|
176
|
+
// the pattern in `useFrameProcessorDriver`.
|
|
177
|
+
const pluginRef = useRef<FrameProcessorPlugin | null>(null);
|
|
178
|
+
useEffect(() => {
|
|
179
|
+
let cancelled = false;
|
|
180
|
+
let timerId: ReturnType<typeof setTimeout> | null = null;
|
|
181
|
+
const tryAcquire = () => {
|
|
182
|
+
if (cancelled) return;
|
|
183
|
+
const p = VisionCameraProxy.initFrameProcessorPlugin(
|
|
184
|
+
'save_frame_as_jpeg',
|
|
185
|
+
{},
|
|
186
|
+
);
|
|
187
|
+
if (p != null) {
|
|
188
|
+
pluginRef.current = p;
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
timerId = setTimeout(tryAcquire, 16);
|
|
192
|
+
};
|
|
193
|
+
tryAcquire();
|
|
194
|
+
return () => {
|
|
195
|
+
cancelled = true;
|
|
196
|
+
if (timerId != null) clearTimeout(timerId);
|
|
197
|
+
};
|
|
198
|
+
}, []);
|
|
199
|
+
|
|
200
|
+
// The worklet body — fires at sampleHz, calls the JPEG plugin,
|
|
201
|
+
// bridges the result to JS. Note we read `pluginRef.current`
|
|
202
|
+
// inside the worklet via the captured `plugin` value below;
|
|
203
|
+
// worklets-core handles the JS↔worklet reference.
|
|
204
|
+
const plugin = pluginRef.current;
|
|
205
|
+
|
|
206
|
+
return useThrottledFrameProcessor(
|
|
207
|
+
(frame: StitcherFrame) => {
|
|
208
|
+
'worklet';
|
|
209
|
+
if (plugin == null) return;
|
|
210
|
+
|
|
211
|
+
// Slot rotation: compute slot from frame timestamp. At
|
|
212
|
+
// sampleHz=2 (500ms interval), the slot index changes every
|
|
213
|
+
// ~1s, giving each slot ~2 samples before being overwritten.
|
|
214
|
+
// That's overkill for the "stream-of-samples" use case but
|
|
215
|
+
// matches the docstring's "at most 4 stale JPEGs" guarantee.
|
|
216
|
+
const slot = Math.floor(frame.timestamp / 1000) % 4;
|
|
217
|
+
const path = `${outputDir}/stream-${slot}.jpg`;
|
|
218
|
+
|
|
219
|
+
// vc's `FrameProcessorPlugin.call` expects vc's `Frame` type.
|
|
220
|
+
// `StitcherFrame` is structurally a superset (it adds `source`,
|
|
221
|
+
// `pose`, AR-only fields). Cast through `unknown` — same
|
|
222
|
+
// pattern v0.8.0's `useFrameProcessor` uses when handing a
|
|
223
|
+
// StitcherFrame-typed worklet to vc.
|
|
224
|
+
const result = plugin.call(frame as unknown as Frame, {
|
|
225
|
+
path,
|
|
226
|
+
quality,
|
|
227
|
+
});
|
|
228
|
+
if (
|
|
229
|
+
result == null ||
|
|
230
|
+
(result as { ok?: boolean }).ok !== true
|
|
231
|
+
) {
|
|
232
|
+
// Native side reported an error (path not writable, format
|
|
233
|
+
// wrong, etc.). Silently skip this sample — the next tick
|
|
234
|
+
// will retry. The plugin already logs the specific reason
|
|
235
|
+
// on the native side.
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
const r = result as {
|
|
239
|
+
path: string;
|
|
240
|
+
width: number;
|
|
241
|
+
height: number;
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
onSampleOnJS({
|
|
245
|
+
jpegPath: r.path,
|
|
246
|
+
pose: frame.pose,
|
|
247
|
+
timestamp: frame.timestamp,
|
|
248
|
+
width: r.width,
|
|
249
|
+
height: r.height,
|
|
250
|
+
});
|
|
251
|
+
},
|
|
252
|
+
{ sampleHz },
|
|
253
|
+
[plugin, outputDir, quality, onSampleOnJS],
|
|
254
|
+
);
|
|
255
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
//
|
|
3
|
+
// v0.9.0 Layer 2 — throttle gate over v0.8.0's `useFrameProcessor`.
|
|
4
|
+
//
|
|
5
|
+
// ## What this is
|
|
6
|
+
//
|
|
7
|
+
// A thin wrapper around `useFrameProcessor` that enforces a maximum
|
|
8
|
+
// invocation rate (`sampleHz`) at the worklet layer. The host's
|
|
9
|
+
// worklet fires up to `sampleHz` times per second; ticks too close
|
|
10
|
+
// together are dropped via a `useSharedValue<number>` monotonic-time
|
|
11
|
+
// gate inside the worklet body.
|
|
12
|
+
//
|
|
13
|
+
// ## When to use this (vs alternatives)
|
|
14
|
+
//
|
|
15
|
+
// - **`useFrameProcessor` directly** — every camera frame (~30-60 Hz).
|
|
16
|
+
// Use for true-realtime processing that wants to see every frame.
|
|
17
|
+
// - **`useThrottledFrameProcessor`** (this hook) — sub-frame-rate
|
|
18
|
+
// worklet-native processing. The worklet runtime has direct
|
|
19
|
+
// access to `frame.toArrayBuffer()`, `frame.arDepth`,
|
|
20
|
+
// `frame.arAnchors`, and can call other vc Frame Processor plugins
|
|
21
|
+
// (native OCR libraries, TFLite ML inference, etc.). Results
|
|
22
|
+
// bridged to JS via `runOnJS`.
|
|
23
|
+
// - **`useFrameStream`** (Layer 3, also in this directory) —
|
|
24
|
+
// sub-frame-rate JS-thread consumer. The lib JPEG-encodes each
|
|
25
|
+
// sample on the producer thread and delivers a `SampledFrame`
|
|
26
|
+
// (file path + pose + dims) to a JS-thread callback. Use for
|
|
27
|
+
// file-path OCR libraries (RN modules wrapping ML Kit etc.),
|
|
28
|
+
// cloud upload, thumbnail UI.
|
|
29
|
+
//
|
|
30
|
+
// ## Use-case mapping (canonical)
|
|
31
|
+
//
|
|
32
|
+
// | Use case | Layer | Why |
|
|
33
|
+
// |---------------------------------------|-------|----------------------------------|
|
|
34
|
+
// | OCR via Vision.framework / ML Kit | **2** | native libs, bbox in frame coords|
|
|
35
|
+
// | TFLite ML detection (via vc plugin) | **2** | same shape as OCR |
|
|
36
|
+
// | LiDAR depth → 3D reconstruction | **2** | depth too large to bridge |
|
|
37
|
+
// | Pose-only telemetry | **2** | tiny payload, no encoding needed |
|
|
38
|
+
// | File-path OCR (RN module) | 3 | host wants a JPEG, not pixels |
|
|
39
|
+
// | Cloud upload (sampled JPEG feed) | 3 | JPEG IS the payload |
|
|
40
|
+
// | Live thumbnail preview UI | 3 | `<Image source={{uri: ...}}>` |
|
|
41
|
+
//
|
|
42
|
+
// See `docs/host-app-integration.md` § "Tier 2 + 3" for recipes.
|
|
43
|
+
//
|
|
44
|
+
// ## Threading
|
|
45
|
+
//
|
|
46
|
+
// The wrapped worklet fires on whatever runtime `useFrameProcessor`
|
|
47
|
+
// dispatches on:
|
|
48
|
+
// - **Non-AR mode**: vision-camera's Frame Processor runtime
|
|
49
|
+
// (producer thread).
|
|
50
|
+
// - **AR mode**: the lib's `RNSARWorkletRuntime` (iOS) /
|
|
51
|
+
// worklets-core default context (Android) — fired by the AR
|
|
52
|
+
// session's per-frame dispatch. See v0.8.0 Phase 4b.i / 4b.iii.
|
|
53
|
+
//
|
|
54
|
+
// Either way, the worklet MUST NOT block — the next frame's
|
|
55
|
+
// processing is gated on this one returning. Long work belongs
|
|
56
|
+
// behind `runOnJS` / a separate worklet runtime.
|
|
57
|
+
//
|
|
58
|
+
// ## Behaviour at the throttle boundary
|
|
59
|
+
//
|
|
60
|
+
// The hook tracks a monotonic-time shared value of "last sample time".
|
|
61
|
+
// Each tick checks if `frame.timestamp - lastSampleMs.value >=
|
|
62
|
+
// (1000 / sampleHz)`. If yes, the worklet body runs and the value
|
|
63
|
+
// updates; if no, the worklet returns silently.
|
|
64
|
+
//
|
|
65
|
+
// Edge cases:
|
|
66
|
+
// - First-ever tick: `lastSampleMs.value` starts at 0; first frame's
|
|
67
|
+
// timestamp will be >> 0 → first tick always fires. Subsequent
|
|
68
|
+
// ticks throttle as expected.
|
|
69
|
+
// - vc v4 timestamp semantics: per the project's worklet-throttle
|
|
70
|
+
// gotcha note, `frame.timestamp` is NOT reliably nanoseconds in
|
|
71
|
+
// vc v4. The hook treats `frame.timestamp` as ALREADY in
|
|
72
|
+
// milliseconds (which is what vc v4 actually delivers; the
|
|
73
|
+
// v0.8.0 StitcherFrame contract documents this). If a future
|
|
74
|
+
// vc version changes the unit, the throttle math here needs
|
|
75
|
+
// re-checking.
|
|
76
|
+
|
|
77
|
+
import type { DependencyList } from 'react';
|
|
78
|
+
import { useSharedValue } from 'react-native-worklets-core';
|
|
79
|
+
|
|
80
|
+
import { useFrameProcessor } from './useFrameProcessor';
|
|
81
|
+
import type {
|
|
82
|
+
StitcherFrame,
|
|
83
|
+
StitcherFrameProcessor,
|
|
84
|
+
} from './StitcherFrame';
|
|
85
|
+
import type { ThrottledFrameProcessorOptions } from '../types';
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Throttled variant of `useFrameProcessor`. See the module
|
|
89
|
+
* docstring for the full use-case mapping; quick version:
|
|
90
|
+
*
|
|
91
|
+
* ```tsx
|
|
92
|
+
* const fp = useThrottledFrameProcessor(
|
|
93
|
+
* (frame) => {
|
|
94
|
+
* 'worklet';
|
|
95
|
+
* // worklet-native OCR / ML / depth processing here
|
|
96
|
+
* },
|
|
97
|
+
* { sampleHz: 2 },
|
|
98
|
+
* [],
|
|
99
|
+
* );
|
|
100
|
+
* return <Camera frameProcessor={fp} ... />;
|
|
101
|
+
* ```
|
|
102
|
+
*
|
|
103
|
+
* @param worklet Host's frame-processor worklet. Must be
|
|
104
|
+
* `'worklet'`-prefixed. Runs at most `sampleHz`
|
|
105
|
+
* times per second.
|
|
106
|
+
* @param options `{ sampleHz }` — clamped to `[0.5, 30]`.
|
|
107
|
+
* @param deps Standard React deps array. Treated the same as
|
|
108
|
+
* `useFrameProcessor`'s deps — when they change the
|
|
109
|
+
* inner worklet is re-bound.
|
|
110
|
+
*
|
|
111
|
+
* @returns A `useFrameProcessor`-shaped processor object, pass it
|
|
112
|
+
* to `<Camera frameProcessor={...}>`.
|
|
113
|
+
*/
|
|
114
|
+
export function useThrottledFrameProcessor(
|
|
115
|
+
worklet: StitcherFrameProcessor,
|
|
116
|
+
options: ThrottledFrameProcessorOptions,
|
|
117
|
+
deps: DependencyList,
|
|
118
|
+
): ReturnType<typeof useFrameProcessor> {
|
|
119
|
+
// Clamp + derive interval. Done outside the worklet so the
|
|
120
|
+
// useSharedValue / useFrameProcessor hooks see stable values.
|
|
121
|
+
const sampleHz = Math.max(0.5, Math.min(30, options.sampleHz));
|
|
122
|
+
const minIntervalMs = 1000 / sampleHz;
|
|
123
|
+
|
|
124
|
+
// Monotonic-time gate. Initialised to 0 → first tick always
|
|
125
|
+
// fires (frame.timestamp >> 0).
|
|
126
|
+
const lastSampleMs = useSharedValue(0);
|
|
127
|
+
|
|
128
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
129
|
+
return useFrameProcessor(
|
|
130
|
+
(frame: StitcherFrame) => {
|
|
131
|
+
'worklet';
|
|
132
|
+
const now = frame.timestamp;
|
|
133
|
+
if (now - lastSampleMs.value < minIntervalMs) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
lastSampleMs.value = now;
|
|
137
|
+
worklet(frame);
|
|
138
|
+
},
|
|
139
|
+
// The throttle interval is captured in the worklet closure; if
|
|
140
|
+
// it changes we need to re-bind the worklet so the new
|
|
141
|
+
// `minIntervalMs` takes effect. Same for the host's worklet
|
|
142
|
+
// identity (so deps changes on the host side re-bind too).
|
|
143
|
+
[minIntervalMs, worklet, ...deps],
|
|
144
|
+
);
|
|
145
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -56,6 +56,101 @@ export interface DeviceMetadata {
|
|
|
56
56
|
// `Camera.tsx` adapts this into the public `CameraCaptureResult` (a
|
|
57
57
|
// discriminated union of photo + panorama) before emitting `onCapture`.
|
|
58
58
|
|
|
59
|
+
/**
|
|
60
|
+
* v0.9.0 Layer 3 — one sampled frame delivered by `useFrameStream`
|
|
61
|
+
* to the JS-thread handler.
|
|
62
|
+
*
|
|
63
|
+
* The JPEG file at `jpegPath` is the stream's own copy. Hosts that
|
|
64
|
+
* need long-term retention MUST copy the file synchronously inside
|
|
65
|
+
* the handler — the same path may be overwritten by a subsequent
|
|
66
|
+
* sample (slot reuse — see the hook's docstring for the rotation
|
|
67
|
+
* policy).
|
|
68
|
+
*/
|
|
69
|
+
export interface SampledFrame {
|
|
70
|
+
/** Absolute filesystem path to the JPEG. No `file://` prefix. */
|
|
71
|
+
jpegPath: string;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Pose at sample time. `translation` is `undefined` in non-AR
|
|
75
|
+
* mode (gyro provides rotation only; no spatial anchor).
|
|
76
|
+
*/
|
|
77
|
+
pose: {
|
|
78
|
+
rotation: [number, number, number, number];
|
|
79
|
+
translation?: [number, number, number];
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** Frame timestamp (ms; per the v0.8.0 StitcherFrame contract). */
|
|
83
|
+
timestamp: number;
|
|
84
|
+
|
|
85
|
+
/** JPEG width / height in pixels. */
|
|
86
|
+
width: number;
|
|
87
|
+
height: number;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* v0.9.0 Layer 3 — options for `useFrameStream`.
|
|
92
|
+
*
|
|
93
|
+
* For worklet-native processing without JPEG roundtrip (OCR via
|
|
94
|
+
* Vision/ML Kit, TFLite ML, LiDAR depth), use
|
|
95
|
+
* `useThrottledFrameProcessor` (Layer 2) instead.
|
|
96
|
+
*/
|
|
97
|
+
export interface FrameStreamOptions {
|
|
98
|
+
/**
|
|
99
|
+
* Target sampling rate in Hertz. Clamped to `[0.5, 10]`. The
|
|
100
|
+
* Layer 2 throttle gate enforces the rate inside the worklet;
|
|
101
|
+
* ticks too close together are dropped silently.
|
|
102
|
+
*
|
|
103
|
+
* Clamp upper bound (10 Hz) is intentionally lower than Layer 2's
|
|
104
|
+
* (30 Hz) — beyond 10 Hz the per-frame JPEG encode + JS-bridge
|
|
105
|
+
* cost dominates the wall-clock budget. Hosts that need higher
|
|
106
|
+
* rates should be on Layer 2 with their own JPEG encoder call
|
|
107
|
+
* (or no JPEG at all).
|
|
108
|
+
*/
|
|
109
|
+
sampleHz: number;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* JPEG quality (0-100). Default 75. Clamped silently to
|
|
113
|
+
* `[1, 100]` by the underlying `save_frame_as_jpeg` native plugin.
|
|
114
|
+
*/
|
|
115
|
+
quality?: number;
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Directory to write JPEG files into. Defaults to a per-app
|
|
119
|
+
* `<cache>/rnis-frame-stream/` subdirectory. The directory is
|
|
120
|
+
* `mkdir -p`'d on first use; hosts that supply an existing
|
|
121
|
+
* absolute path are responsible for its lifecycle.
|
|
122
|
+
*/
|
|
123
|
+
outputDir?: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* v0.9.0 Layer 2 — options for `useThrottledFrameProcessor`.
|
|
128
|
+
*
|
|
129
|
+
* Wraps v0.8.0's `useFrameProcessor` with a monotonic-time throttle
|
|
130
|
+
* gate so the supplied worklet fires at most `sampleHz` times per
|
|
131
|
+
* second. Use for sub-frame-rate worklet-native processing — native
|
|
132
|
+
* OCR (Vision.framework / ML Kit), TFLite ML detection, LiDAR depth
|
|
133
|
+
* processing — where the bbox / depth payloads are small enough to
|
|
134
|
+
* bridge to JS via `runOnJS`.
|
|
135
|
+
*
|
|
136
|
+
* For JS-thread JPEG consumers (file-path OCR libraries, cloud
|
|
137
|
+
* upload, thumbnail UI), use `useFrameStream` (Layer 3) instead.
|
|
138
|
+
*/
|
|
139
|
+
export interface ThrottledFrameProcessorOptions {
|
|
140
|
+
/**
|
|
141
|
+
* Target sampling rate in Hertz. Clamped to `[0.5, 30]`. Inside
|
|
142
|
+
* the worklet a monotonic-time gate enforces the rate; ticks too
|
|
143
|
+
* close together are silently dropped.
|
|
144
|
+
*
|
|
145
|
+
* The clamp upper bound (30 Hz) sits at typical AR rates on
|
|
146
|
+
* mid-range Android devices — beyond that, the host should just
|
|
147
|
+
* use `useFrameProcessor` directly (no throttle). The clamp
|
|
148
|
+
* lower bound (0.5 Hz) prevents accidentally-zero-divide values
|
|
149
|
+
* + matches `useFrameStream`'s convention.
|
|
150
|
+
*/
|
|
151
|
+
sampleHz: number;
|
|
152
|
+
}
|
|
153
|
+
|
|
59
154
|
export interface CaptureResult {
|
|
60
155
|
/** Unique device-generated UUID */
|
|
61
156
|
deviceUuid: string;
|