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.
Files changed (58) hide show
  1. package/CHANGELOG.md +241 -0
  2. package/android/build.gradle +35 -1
  3. package/android/src/main/cpp/CMakeLists.txt +64 -2
  4. package/android/src/main/cpp/stitcher_jsi_install_jni.cpp +227 -0
  5. package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +30 -11
  6. package/android/src/main/java/io/imagestitcher/rn/RNImageStitcherPackage.kt +21 -3
  7. package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +78 -3
  8. package/android/src/main/java/io/imagestitcher/rn/SaveFrameAsJpegPlugin.kt +162 -0
  9. package/android/src/main/java/io/imagestitcher/rn/StitcherJsiInstallerModule.kt +103 -0
  10. package/android/src/main/java/io/imagestitcher/rn/StitcherWorkletRuntime.kt +256 -0
  11. package/android/src/main/java/io/imagestitcher/rn/TransferredNV21.kt +100 -0
  12. package/cpp/stitcher_frame_data.hpp +141 -0
  13. package/cpp/stitcher_frame_jsi.cpp +214 -0
  14. package/cpp/stitcher_frame_jsi.hpp +108 -0
  15. package/cpp/stitcher_proxy_jsi.cpp +109 -0
  16. package/cpp/stitcher_proxy_jsi.hpp +46 -0
  17. package/cpp/stitcher_worklet_dispatch.cpp +103 -0
  18. package/cpp/stitcher_worklet_dispatch.hpp +71 -0
  19. package/cpp/stitcher_worklet_registry.cpp +81 -0
  20. package/cpp/stitcher_worklet_registry.hpp +136 -0
  21. package/dist/camera/Camera.d.ts +62 -12
  22. package/dist/camera/Camera.js +30 -15
  23. package/dist/index.d.ts +6 -0
  24. package/dist/index.js +30 -1
  25. package/dist/stitching/StitcherFrame.d.ts +170 -0
  26. package/dist/stitching/StitcherFrame.js +4 -0
  27. package/dist/stitching/StitcherWorkletRegistry.d.ts +117 -0
  28. package/dist/stitching/StitcherWorkletRegistry.js +78 -0
  29. package/dist/stitching/ensureStitcherProxyInstalled.d.ts +8 -0
  30. package/dist/stitching/ensureStitcherProxyInstalled.js +81 -0
  31. package/dist/stitching/useFrameProcessor.d.ts +119 -0
  32. package/dist/stitching/useFrameProcessor.js +196 -0
  33. package/dist/stitching/useFrameStream.d.ts +34 -0
  34. package/dist/stitching/useFrameStream.js +219 -0
  35. package/dist/stitching/useThrottledFrameProcessor.d.ts +33 -0
  36. package/dist/stitching/useThrottledFrameProcessor.js +132 -0
  37. package/dist/types.d.ts +87 -0
  38. package/ios/Sources/RNImageStitcher/RNSARSession.swift +46 -10
  39. package/ios/Sources/RNImageStitcher/RNSARWorkletRuntime.h +128 -0
  40. package/ios/Sources/RNImageStitcher/RNSARWorkletRuntime.mm +313 -0
  41. package/ios/Sources/RNImageStitcher/SaveFrameAsJpegPlugin.mm +185 -0
  42. package/ios/Sources/RNImageStitcher/StitcherFrameHostObject.h +60 -0
  43. package/ios/Sources/RNImageStitcher/StitcherFrameHostObject.mm +214 -0
  44. package/ios/Sources/RNImageStitcher/StitcherJsiInstaller.h +42 -0
  45. package/ios/Sources/RNImageStitcher/StitcherJsiInstaller.mm +103 -0
  46. package/package.json +1 -1
  47. package/src/camera/Camera.tsx +93 -28
  48. package/src/index.ts +35 -0
  49. package/src/stitching/StitcherFrame.ts +197 -0
  50. package/src/stitching/StitcherWorkletRegistry.ts +156 -0
  51. package/src/stitching/__tests__/StitcherWorkletRegistry.test.ts +176 -0
  52. package/src/stitching/__tests__/ensureStitcherProxyInstalled.test.ts +94 -0
  53. package/src/stitching/__tests__/useThrottledFrameProcessor.test.ts +178 -0
  54. package/src/stitching/ensureStitcherProxyInstalled.ts +141 -0
  55. package/src/stitching/useFrameProcessor.ts +226 -0
  56. package/src/stitching/useFrameStream.ts +255 -0
  57. package/src/stitching/useThrottledFrameProcessor.ts +145 -0
  58. 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;