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.
Files changed (36) hide show
  1. package/CHANGELOG.md +269 -0
  2. package/android/build.gradle +10 -0
  3. package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +115 -10
  4. package/android/src/main/java/io/imagestitcher/rn/RNImageStitcherPackage.kt +17 -3
  5. package/android/src/main/java/io/imagestitcher/rn/SaveFrameAsJpegPlugin.kt +162 -0
  6. package/cpp/stitcher_worklet_registry.cpp +10 -0
  7. package/cpp/stitcher_worklet_registry.hpp +10 -0
  8. package/cpp/tests/CMakeLists.txt +98 -0
  9. package/cpp/tests/README.md +86 -0
  10. package/cpp/tests/pose_test.cpp +74 -0
  11. package/cpp/tests/stitcher_frame_data_test.cpp +132 -0
  12. package/cpp/tests/stitcher_worklet_registry_test.cpp +195 -0
  13. package/cpp/tests/stubs/jsi/jsi.h +33 -0
  14. package/cpp/tests/stubs/react-native-worklets-core/WKTJsiWorklet.h +34 -0
  15. package/dist/camera/useCapture.d.ts +1 -1
  16. package/dist/camera/useCapture.js +1 -1
  17. package/dist/index.d.ts +4 -0
  18. package/dist/index.js +20 -1
  19. package/dist/stitching/incremental.d.ts +41 -0
  20. package/dist/stitching/useFrameStream.d.ts +34 -0
  21. package/dist/stitching/useFrameStream.js +234 -0
  22. package/dist/stitching/useThrottledFrameProcessor.d.ts +33 -0
  23. package/dist/stitching/useThrottledFrameProcessor.js +132 -0
  24. package/dist/types.d.ts +87 -0
  25. package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +138 -9
  26. package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.swift +50 -14
  27. package/ios/Sources/RNImageStitcher/SaveFrameAsJpegPlugin.mm +185 -0
  28. package/package.json +1 -1
  29. package/src/camera/useCapture.ts +1 -1
  30. package/src/index.ts +19 -0
  31. package/src/stitching/__tests__/subscribeIncrementalState.refine.test.ts +276 -0
  32. package/src/stitching/__tests__/useThrottledFrameProcessor.test.ts +178 -0
  33. package/src/stitching/incremental.ts +42 -0
  34. package/src/stitching/useFrameStream.ts +271 -0
  35. package/src/stitching/useThrottledFrameProcessor.ts +145 -0
  36. package/src/types.ts +95 -0
@@ -19,7 +19,7 @@
19
19
  * - This hook does NOT persist captures. Host apps hand the
20
20
  * returned CaptureResult to their own storage layer (WatermelonDB
21
21
  * insert, Redux dispatch, whatever).
22
- * - Video recording lives in useVideoCapture (TODO).
22
+ * - Video recording lives in useVideoCapture.
23
23
  *
24
24
  * The public API is designed to be minimal and replaceable: host apps
25
25
  * that prefer the raw vision-camera API can opt out of this hook and
package/dist/index.d.ts CHANGED
@@ -67,6 +67,10 @@ export { useIncrementalStitcher } from './stitching/useIncrementalStitcher';
67
67
  export { useKeyframeStream } from './stitching/useKeyframeStream';
68
68
  export type { StitcherFrame, StitcherFrameProcessor, ARAnchor, } from './stitching/StitcherFrame';
69
69
  export { useFrameProcessor } from './stitching/useFrameProcessor';
70
+ export { useThrottledFrameProcessor } from './stitching/useThrottledFrameProcessor';
71
+ export type { ThrottledFrameProcessorOptions } from './types';
72
+ export { useFrameStream } from './stitching/useFrameStream';
73
+ export type { FrameStreamOptions, SampledFrame } from './types';
70
74
  export { useFrameProcessorDriver } from './stitching/useFrameProcessorDriver';
71
75
  export type { UseFrameProcessorDriverOptions, FrameProcessorDriverHandle, } from './stitching/useFrameProcessorDriver';
72
76
  export { stitchVideo } from './stitching/stitchVideo';
package/dist/index.js CHANGED
@@ -22,7 +22,7 @@
22
22
  * adds RetaiLens-specific features on top.
23
23
  */
24
24
  Object.defineProperty(exports, "__esModule", { value: true });
25
- exports.stitchVideo = exports.useFrameProcessorDriver = exports.useFrameProcessor = exports.useKeyframeStream = exports.useIncrementalStitcher = exports.cleanupOldKeyframes = exports.getIncrementalNativeModule = exports.subscribeIncrementalState = exports.incrementalStitcherIsAvailable = exports.IncrementalOutcome = exports.useDeviceOrientation = exports.useVideoCapture = exports.useCapture = exports.ViewportCropOverlay = exports.hybridSettingsToNativeConfig = exports.slitscanSettingsToNativeConfig = exports.panoramaSettingsToNativeConfig = exports.DEFAULT_HYBRID_SETTINGS = exports.DEFAULT_SLITSCAN_SETTINGS = exports.DEFAULT_FLOW_GATE_SETTINGS = exports.DEFAULT_PANORAMA_SETTINGS = exports.PanoramaSettingsModal = exports.PanoramaGuidance = exports.PanoramaBandOverlay = exports.IncrementalPanGuide = exports.CaptureThumbnailStrip = exports.useStitchStatsToast = exports.CaptureStitchStatsToast = exports.CaptureOrientationPill = exports.CaptureKeyframePill = exports.CaptureMemoryPill = exports.CaptureDebugOverlay = exports.CaptureStatusOverlay = exports.CapturePreview = exports.CaptureControlsBar = exports.CaptureHeader = exports.CameraView = exports.ARCameraView = exports.useIMUTranslationGate = exports.ARTrackingState = exports.useARSession = exports.CameraError = exports.Camera = void 0;
25
+ exports.stitchVideo = exports.useFrameProcessorDriver = exports.useFrameStream = exports.useThrottledFrameProcessor = exports.useFrameProcessor = exports.useKeyframeStream = exports.useIncrementalStitcher = exports.cleanupOldKeyframes = exports.getIncrementalNativeModule = exports.subscribeIncrementalState = exports.incrementalStitcherIsAvailable = exports.IncrementalOutcome = exports.useDeviceOrientation = exports.useVideoCapture = exports.useCapture = exports.ViewportCropOverlay = exports.hybridSettingsToNativeConfig = exports.slitscanSettingsToNativeConfig = exports.panoramaSettingsToNativeConfig = exports.DEFAULT_HYBRID_SETTINGS = exports.DEFAULT_SLITSCAN_SETTINGS = exports.DEFAULT_FLOW_GATE_SETTINGS = exports.DEFAULT_PANORAMA_SETTINGS = exports.PanoramaSettingsModal = exports.PanoramaGuidance = exports.PanoramaBandOverlay = exports.IncrementalPanGuide = exports.CaptureThumbnailStrip = exports.useStitchStatsToast = exports.CaptureStitchStatsToast = exports.CaptureOrientationPill = exports.CaptureKeyframePill = exports.CaptureMemoryPill = exports.CaptureDebugOverlay = exports.CaptureStatusOverlay = exports.CapturePreview = exports.CaptureControlsBar = exports.CaptureHeader = exports.CameraView = exports.ARCameraView = exports.useIMUTranslationGate = exports.ARTrackingState = exports.useARSession = exports.CameraError = exports.Camera = void 0;
26
26
  // ─────────────────────────────────────────────────────────────────────
27
27
  // Layer 1 — the high-level <Camera> component
28
28
  // ─────────────────────────────────────────────────────────────────────
@@ -155,6 +155,25 @@ Object.defineProperty(exports, "useKeyframeStream", { enumerable: true, get: fun
155
155
  // See the hook's docstring + StitcherFrame.ts for the contract.
156
156
  var useFrameProcessor_1 = require("./stitching/useFrameProcessor");
157
157
  Object.defineProperty(exports, "useFrameProcessor", { enumerable: true, get: function () { return useFrameProcessor_1.useFrameProcessor; } });
158
+ // v0.9.0 Layer 2 — `useThrottledFrameProcessor`. Throttle gate over
159
+ // `useFrameProcessor` for sub-frame-rate worklet-native processing
160
+ // (native OCR via Vision.framework / ML Kit, TFLite ML detection,
161
+ // LiDAR depth). The worklet runtime has direct access to
162
+ // `frame.toArrayBuffer()` / `frame.arDepth`; bridge small payloads
163
+ // (bboxes, depth-derived metrics) to JS via `runOnJS`. For JS-thread
164
+ // JPEG consumers (file-path OCR libs, cloud upload, thumbnail UI),
165
+ // prefer `useFrameStream` (Layer 3, ships in the same release).
166
+ var useThrottledFrameProcessor_1 = require("./stitching/useThrottledFrameProcessor");
167
+ Object.defineProperty(exports, "useThrottledFrameProcessor", { enumerable: true, get: function () { return useThrottledFrameProcessor_1.useThrottledFrameProcessor; } });
168
+ // v0.9.0 Layer 3 — `useFrameStream`. JS-thread sampled-frame
169
+ // stream over Layer 1 (`save_frame_as_jpeg` vc plugin) + Layer 2
170
+ // (`useThrottledFrameProcessor`). Use for JS-thread consumers:
171
+ // file-path OCR libs (RN modules), cloud upload, thumbnail UI.
172
+ // For worklet-native processing (Vision/ML Kit as vc plugins,
173
+ // TFLite ML, LiDAR depth), prefer `useThrottledFrameProcessor`
174
+ // (Layer 2) — lower latency, no JPEG roundtrip.
175
+ var useFrameStream_1 = require("./stitching/useFrameStream");
176
+ Object.defineProperty(exports, "useFrameStream", { enumerable: true, get: function () { return useFrameStream_1.useFrameStream; } });
158
177
  // vision-camera Frame Processor driver for non-AR captures. As
159
178
  // of v0.6 the only non-AR driver exported (the legacy
160
179
  // `useIncrementalJSDriver` was removed; was deprecated in v0.5).
@@ -249,6 +249,47 @@ export interface IncrementalState {
249
249
  * keyframes on disk.
250
250
  */
251
251
  refinedPanoramaPath?: string;
252
+ /**
253
+ * v0.10.0 (#15A) — current phase of an in-flight `refinePanorama`
254
+ * call. Fires from both the explicit `module.refinePanorama(...)`
255
+ * JS API path AND the hybrid-engine auto-refine path (which calls
256
+ * the same native refinePanorama internally).
257
+ *
258
+ * Lifecycle:
259
+ * - `"validating"` (fraction 0.05) — synchronous input checks
260
+ * - `"stitching"` (fraction 0.10) — OpenCV stitch in flight
261
+ * - `"writing"` (fraction 0.90) — stitch done, JPEG written
262
+ * - `"done"` (fraction 1.00) — success
263
+ * - `"error"` (fraction 1.00) — failure; `refineError` is set
264
+ *
265
+ * Coarse on purpose: OpenCV's Stitcher doesn't expose mid-pipeline
266
+ * progress, so the 0.10 → 0.90 jump is one opaque step. Use
267
+ * `refineStage` for a stage label; use `refineProgress` purely for
268
+ * spinner progress.
269
+ *
270
+ * Undefined when no refinement is in flight.
271
+ */
272
+ refineStage?: 'validating' | 'stitching' | 'writing' | 'done' | 'error';
273
+ /**
274
+ * v0.10.0 (#15A) — coarse progress fraction in `[0, 1]` aligned
275
+ * with `refineStage`. See `refineStage` for the per-stage value
276
+ * mapping. Undefined when no refinement is in flight.
277
+ */
278
+ refineProgress?: number;
279
+ /**
280
+ * v0.10.0 (#15A) — number of input frames the in-flight refine is
281
+ * processing. Useful for the UI label
282
+ * (`Stitching 6 frames…`). Mirrors the `framesRequested` field
283
+ * returned in the explicit refinePanorama resolution. Undefined
284
+ * when no refinement is in flight.
285
+ */
286
+ refineFrames?: number;
287
+ /**
288
+ * v0.10.0 (#15A) — present only when `refineStage === 'error'`.
289
+ * Human-readable error message; the same text the rejected promise
290
+ * carries. Use to render a one-line failure pill.
291
+ */
292
+ refineError?: string;
252
293
  }
253
294
  export interface IncrementalStartOptions {
254
295
  /**
@@ -0,0 +1,34 @@
1
+ import { useThrottledFrameProcessor } from './useThrottledFrameProcessor';
2
+ import type { FrameStreamOptions, SampledFrame } from '../types';
3
+ /**
4
+ * `useFrameStream` — Layer 3. See module docstring for the full
5
+ * design + use-case mapping. Quick start:
6
+ *
7
+ * ```tsx
8
+ * import { Camera, useFrameStream } from 'react-native-image-stitcher';
9
+ *
10
+ * function MyScreen() {
11
+ * const fp = useFrameStream(
12
+ * { sampleHz: 2, quality: 75 },
13
+ * (sample) => {
14
+ * setThumbnail(sample.jpegPath);
15
+ * },
16
+ * );
17
+ * return <Camera frameProcessor={fp} ... />;
18
+ * }
19
+ * ```
20
+ *
21
+ * @param options `{ sampleHz, quality?, outputDir? }`. `sampleHz`
22
+ * clamped to `[0.5, 10]`.
23
+ * @param handler JS-thread callback fired per sample. Receives a
24
+ * `SampledFrame`. May return a Promise; rejections
25
+ * are caught + logged (not re-thrown) so one
26
+ * misbehaving handler doesn't break the stream.
27
+ *
28
+ * @returns A `useFrameProcessor`-shaped processor object — pass to
29
+ * `<Camera frameProcessor={...}>` for non-AR mode wiring.
30
+ * (AR mode auto-registration via `__stitcherProxy` is
31
+ * handled inside `useFrameProcessor`.)
32
+ */
33
+ export declare function useFrameStream(options: FrameStreamOptions, handler: (sample: SampledFrame) => void | Promise<void>): ReturnType<typeof useThrottledFrameProcessor>;
34
+ //# sourceMappingURL=useFrameStream.d.ts.map
@@ -0,0 +1,234 @@
1
+ "use strict";
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ //
4
+ // v0.9.0 Layer 3 — JS-thread sampled-frame stream over Layer 1 +
5
+ // Layer 2.
6
+ //
7
+ // ## What this is
8
+ //
9
+ // A hook that:
10
+ // 1. Throttles a worklet via `useThrottledFrameProcessor` (Layer 2)
11
+ // to fire at `sampleHz` Hz.
12
+ // 2. Inside the worklet, calls the `save_frame_as_jpeg` vc Frame
13
+ // Processor plugin (Layer 1) to JPEG-encode the frame to a
14
+ // bounded-rotation slot on disk.
15
+ // 3. Bridges the resulting `SampledFrame` (file path + pose +
16
+ // dims) to a JS-thread callback via `runOnJS`.
17
+ //
18
+ // The host gets a per-sample callback on the JS thread with a file
19
+ // path they can pass to `<Image>`, an OCR RN module, a cloud-upload
20
+ // library, etc. Zero worklet boilerplate.
21
+ //
22
+ // ## When to use this (vs alternatives)
23
+ //
24
+ // - **`useFrameStream`** (this hook) — JS-thread consumers. File-
25
+ // path OCR libraries, cloud upload, thumbnail UI, sampled
26
+ // server-side analysis.
27
+ // - **`useThrottledFrameProcessor`** (Layer 2) — worklet-native
28
+ // consumers. Native OCR (Vision.framework / ML Kit) wrapped as
29
+ // vc plugins, TFLite ML inference, LiDAR depth processing.
30
+ // Lower latency; no JPEG roundtrip.
31
+ // - **`useFrameProcessor`** — every camera frame; full control.
32
+ //
33
+ // ## Slot reuse / disk usage
34
+ //
35
+ // JPEG files are written to `<outputDir>/stream-<N>.jpg` where N
36
+ // cycles 0..3 based on `frame.timestamp / 1000`. At most 4 stale
37
+ // JPEGs ever exist on disk; the same file is rewritten on each
38
+ // rotation, so disk usage is bounded.
39
+ //
40
+ // Hosts that need long-term retention (e.g., archive each sample
41
+ // for later upload) MUST copy the file synchronously inside the
42
+ // handler — the slot may be overwritten by the next sample.
43
+ //
44
+ // ## Backpressure
45
+ //
46
+ // If the JS handler returns slower than `1/sampleHz`, subsequent
47
+ // ticks DO still fire (the throttle is time-based, not handler-
48
+ // completion-based). This means multiple handler invocations can
49
+ // be in flight simultaneously. For most use cases that's fine
50
+ // (the handlers are pure or commute). Hosts that need serialised
51
+ // handling should track in-flight state themselves and early-return.
52
+ //
53
+ // ## AR vs non-AR
54
+ //
55
+ // Works in both modes because it composes over
56
+ // `useThrottledFrameProcessor` → `useFrameProcessor`. In AR mode
57
+ // the worklet auto-registers via `__stitcherProxy` (v0.8.0 Phase
58
+ // 4b.i/iii); in non-AR mode the returned processor object is
59
+ // passed to `<Camera frameProcessor={...}>`. The hook returns
60
+ // the processor object so hosts can wire it up either way.
61
+ Object.defineProperty(exports, "__esModule", { value: true });
62
+ exports.useFrameStream = useFrameStream;
63
+ const react_1 = require("react");
64
+ const react_native_vision_camera_1 = require("react-native-vision-camera");
65
+ const react_native_worklets_core_1 = require("react-native-worklets-core");
66
+ const useThrottledFrameProcessor_1 = require("./useThrottledFrameProcessor");
67
+ const files_1 = require("../utils/files");
68
+ /**
69
+ * `useFrameStream` — Layer 3. See module docstring for the full
70
+ * design + use-case mapping. Quick start:
71
+ *
72
+ * ```tsx
73
+ * import { Camera, useFrameStream } from 'react-native-image-stitcher';
74
+ *
75
+ * function MyScreen() {
76
+ * const fp = useFrameStream(
77
+ * { sampleHz: 2, quality: 75 },
78
+ * (sample) => {
79
+ * setThumbnail(sample.jpegPath);
80
+ * },
81
+ * );
82
+ * return <Camera frameProcessor={fp} ... />;
83
+ * }
84
+ * ```
85
+ *
86
+ * @param options `{ sampleHz, quality?, outputDir? }`. `sampleHz`
87
+ * clamped to `[0.5, 10]`.
88
+ * @param handler JS-thread callback fired per sample. Receives a
89
+ * `SampledFrame`. May return a Promise; rejections
90
+ * are caught + logged (not re-thrown) so one
91
+ * misbehaving handler doesn't break the stream.
92
+ *
93
+ * @returns A `useFrameProcessor`-shaped processor object — pass to
94
+ * `<Camera frameProcessor={...}>` for non-AR mode wiring.
95
+ * (AR mode auto-registration via `__stitcherProxy` is
96
+ * handled inside `useFrameProcessor`.)
97
+ */
98
+ function useFrameStream(options, handler) {
99
+ const sampleHz = Math.max(0.5, Math.min(10, options.sampleHz));
100
+ const quality = options.quality ?? 75;
101
+ // Default output dir: the lib's canonical capture dir resolved
102
+ // via `FileBridge.defaultCaptureDir()`. Same dir the lib uses
103
+ // for panorama JPEGs / keyframe JPEGs — guaranteed writable on
104
+ // both platforms (iOS NSCachesDirectory + Android Context.cacheDir),
105
+ // created if missing. Resolved async on first mount; until
106
+ // resolution completes the worklet's `outputDir` is empty and
107
+ // the plugin call no-ops silently (a few frames missed at most;
108
+ // typical resolution time is <50ms).
109
+ //
110
+ // Hosts that want a specific path supply `options.outputDir`
111
+ // and skip the resolution entirely.
112
+ const [resolvedDefaultDir, setResolvedDefaultDir] = (0, react_1.useState)('');
113
+ (0, react_1.useEffect)(() => {
114
+ if (options.outputDir != null)
115
+ return;
116
+ let cancelled = false;
117
+ (0, files_1.getDefaultCaptureDir)()
118
+ .then((dir) => {
119
+ if (!cancelled)
120
+ setResolvedDefaultDir(dir);
121
+ })
122
+ .catch((err) => {
123
+ // eslint-disable-next-line no-console
124
+ console.warn('[useFrameStream] FileBridge.defaultCaptureDir() failed; ' +
125
+ 'samples will not fire until `options.outputDir` is supplied. ' +
126
+ String(err));
127
+ });
128
+ return () => {
129
+ cancelled = true;
130
+ };
131
+ }, [options.outputDir]);
132
+ const outputDir = options.outputDir ?? resolvedDefaultDir;
133
+ // Stable JS-side handler reference for `runOnJS`. The hook re-
134
+ // captures `handler` on every render but the ref keeps the
135
+ // worklet closure pointing at the latest callback (avoid stale
136
+ // captures).
137
+ const handlerRef = (0, react_1.useRef)(handler);
138
+ handlerRef.current = handler;
139
+ const onSampleJS = (0, react_1.useCallback)((sample) => {
140
+ const result = handlerRef.current(sample);
141
+ if (result != null &&
142
+ typeof result.catch === 'function') {
143
+ result.catch((err) => {
144
+ // eslint-disable-next-line no-console
145
+ console.error('[useFrameStream] handler threw:', err);
146
+ });
147
+ }
148
+ }, []);
149
+ const onSampleOnJS = (0, react_1.useMemo)(() => react_native_worklets_core_1.Worklets.createRunOnJS(onSampleJS), [onSampleJS]);
150
+ // ── Plugin acquisition (Layer 1) ─────────────────────────────────
151
+ //
152
+ // `initFrameProcessorPlugin` can return `undefined` if the native
153
+ // registry hasn't initialised yet (rare race on app start). We
154
+ // retry every 16ms (one display frame) until success — matches
155
+ // the pattern in `useFrameProcessorDriver`.
156
+ //
157
+ // Use `useState` (not `useRef`) so the eventual non-null value
158
+ // triggers a re-render — the worklet closure below captures
159
+ // `plugin` by value at render time, so without state we'd
160
+ // capture `null` forever.
161
+ const [plugin, setPlugin] = (0, react_1.useState)(null);
162
+ (0, react_1.useEffect)(() => {
163
+ let cancelled = false;
164
+ let timerId = null;
165
+ let attempts = 0;
166
+ const tryAcquire = () => {
167
+ if (cancelled)
168
+ return;
169
+ attempts += 1;
170
+ const p = react_native_vision_camera_1.VisionCameraProxy.initFrameProcessorPlugin('save_frame_as_jpeg', {});
171
+ if (p != null) {
172
+ setPlugin(p);
173
+ return;
174
+ }
175
+ // After ~1s of failed retries, warn once — the plugin should
176
+ // be registered by then; persistent failure means the host's
177
+ // native bundle doesn't include `save_frame_as_jpeg`.
178
+ if (attempts === 60) {
179
+ // eslint-disable-next-line no-console
180
+ console.warn('[useFrameStream] save_frame_as_jpeg plugin not found after 1s of retries. ' +
181
+ 'Verify react-native-image-stitcher native module is installed in your host app.');
182
+ }
183
+ timerId = setTimeout(tryAcquire, 16);
184
+ };
185
+ tryAcquire();
186
+ return () => {
187
+ cancelled = true;
188
+ if (timerId != null)
189
+ clearTimeout(timerId);
190
+ };
191
+ }, []);
192
+ return (0, useThrottledFrameProcessor_1.useThrottledFrameProcessor)((frame) => {
193
+ 'worklet';
194
+ if (plugin == null)
195
+ return;
196
+ // Async outputDir resolution may not have completed yet on
197
+ // the first few frames after mount — bail until it does.
198
+ if (outputDir === '')
199
+ return;
200
+ // Slot rotation: compute slot from frame timestamp. At
201
+ // sampleHz=2 (500ms interval), the slot index changes every
202
+ // ~1s, giving each slot ~2 samples before being overwritten.
203
+ // That's overkill for the "stream-of-samples" use case but
204
+ // matches the docstring's "at most 4 stale JPEGs" guarantee.
205
+ const slot = Math.floor(frame.timestamp / 1000) % 4;
206
+ const path = `${outputDir}/stream-${slot}.jpg`;
207
+ // vc's `FrameProcessorPlugin.call` expects vc's `Frame` type.
208
+ // `StitcherFrame` is structurally a superset (it adds `source`,
209
+ // `pose`, AR-only fields). Cast through `unknown` — same
210
+ // pattern v0.8.0's `useFrameProcessor` uses when handing a
211
+ // StitcherFrame-typed worklet to vc.
212
+ const result = plugin.call(frame, {
213
+ path,
214
+ quality,
215
+ });
216
+ if (result == null ||
217
+ result.ok !== true) {
218
+ // Native side reported an error (path not writable, format
219
+ // wrong, etc.). Silently skip this sample — the next tick
220
+ // will retry. The plugin already logs the specific reason
221
+ // on the native side.
222
+ return;
223
+ }
224
+ const r = result;
225
+ onSampleOnJS({
226
+ jpegPath: r.path,
227
+ pose: frame.pose,
228
+ timestamp: frame.timestamp,
229
+ width: r.width,
230
+ height: r.height,
231
+ });
232
+ }, { sampleHz }, [plugin, outputDir, quality, onSampleOnJS]);
233
+ }
234
+ //# sourceMappingURL=useFrameStream.js.map
@@ -0,0 +1,33 @@
1
+ import type { DependencyList } from 'react';
2
+ import { useFrameProcessor } from './useFrameProcessor';
3
+ import type { StitcherFrameProcessor } from './StitcherFrame';
4
+ import type { ThrottledFrameProcessorOptions } from '../types';
5
+ /**
6
+ * Throttled variant of `useFrameProcessor`. See the module
7
+ * docstring for the full use-case mapping; quick version:
8
+ *
9
+ * ```tsx
10
+ * const fp = useThrottledFrameProcessor(
11
+ * (frame) => {
12
+ * 'worklet';
13
+ * // worklet-native OCR / ML / depth processing here
14
+ * },
15
+ * { sampleHz: 2 },
16
+ * [],
17
+ * );
18
+ * return <Camera frameProcessor={fp} ... />;
19
+ * ```
20
+ *
21
+ * @param worklet Host's frame-processor worklet. Must be
22
+ * `'worklet'`-prefixed. Runs at most `sampleHz`
23
+ * times per second.
24
+ * @param options `{ sampleHz }` — clamped to `[0.5, 30]`.
25
+ * @param deps Standard React deps array. Treated the same as
26
+ * `useFrameProcessor`'s deps — when they change the
27
+ * inner worklet is re-bound.
28
+ *
29
+ * @returns A `useFrameProcessor`-shaped processor object, pass it
30
+ * to `<Camera frameProcessor={...}>`.
31
+ */
32
+ export declare function useThrottledFrameProcessor(worklet: StitcherFrameProcessor, options: ThrottledFrameProcessorOptions, deps: DependencyList): ReturnType<typeof useFrameProcessor>;
33
+ //# sourceMappingURL=useThrottledFrameProcessor.d.ts.map
@@ -0,0 +1,132 @@
1
+ "use strict";
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ //
4
+ // v0.9.0 Layer 2 — throttle gate over v0.8.0's `useFrameProcessor`.
5
+ //
6
+ // ## What this is
7
+ //
8
+ // A thin wrapper around `useFrameProcessor` that enforces a maximum
9
+ // invocation rate (`sampleHz`) at the worklet layer. The host's
10
+ // worklet fires up to `sampleHz` times per second; ticks too close
11
+ // together are dropped via a `useSharedValue<number>` monotonic-time
12
+ // gate inside the worklet body.
13
+ //
14
+ // ## When to use this (vs alternatives)
15
+ //
16
+ // - **`useFrameProcessor` directly** — every camera frame (~30-60 Hz).
17
+ // Use for true-realtime processing that wants to see every frame.
18
+ // - **`useThrottledFrameProcessor`** (this hook) — sub-frame-rate
19
+ // worklet-native processing. The worklet runtime has direct
20
+ // access to `frame.toArrayBuffer()`, `frame.arDepth`,
21
+ // `frame.arAnchors`, and can call other vc Frame Processor plugins
22
+ // (native OCR libraries, TFLite ML inference, etc.). Results
23
+ // bridged to JS via `runOnJS`.
24
+ // - **`useFrameStream`** (Layer 3, also in this directory) —
25
+ // sub-frame-rate JS-thread consumer. The lib JPEG-encodes each
26
+ // sample on the producer thread and delivers a `SampledFrame`
27
+ // (file path + pose + dims) to a JS-thread callback. Use for
28
+ // file-path OCR libraries (RN modules wrapping ML Kit etc.),
29
+ // cloud upload, thumbnail UI.
30
+ //
31
+ // ## Use-case mapping (canonical)
32
+ //
33
+ // | Use case | Layer | Why |
34
+ // |---------------------------------------|-------|----------------------------------|
35
+ // | OCR via Vision.framework / ML Kit | **2** | native libs, bbox in frame coords|
36
+ // | TFLite ML detection (via vc plugin) | **2** | same shape as OCR |
37
+ // | LiDAR depth → 3D reconstruction | **2** | depth too large to bridge |
38
+ // | Pose-only telemetry | **2** | tiny payload, no encoding needed |
39
+ // | File-path OCR (RN module) | 3 | host wants a JPEG, not pixels |
40
+ // | Cloud upload (sampled JPEG feed) | 3 | JPEG IS the payload |
41
+ // | Live thumbnail preview UI | 3 | `<Image source={{uri: ...}}>` |
42
+ //
43
+ // See `docs/host-app-integration.md` § "Tier 2 + 3" for recipes.
44
+ //
45
+ // ## Threading
46
+ //
47
+ // The wrapped worklet fires on whatever runtime `useFrameProcessor`
48
+ // dispatches on:
49
+ // - **Non-AR mode**: vision-camera's Frame Processor runtime
50
+ // (producer thread).
51
+ // - **AR mode**: the lib's `RNSARWorkletRuntime` (iOS) /
52
+ // worklets-core default context (Android) — fired by the AR
53
+ // session's per-frame dispatch. See v0.8.0 Phase 4b.i / 4b.iii.
54
+ //
55
+ // Either way, the worklet MUST NOT block — the next frame's
56
+ // processing is gated on this one returning. Long work belongs
57
+ // behind `runOnJS` / a separate worklet runtime.
58
+ //
59
+ // ## Behaviour at the throttle boundary
60
+ //
61
+ // The hook tracks a monotonic-time shared value of "last sample time".
62
+ // Each tick checks if `frame.timestamp - lastSampleMs.value >=
63
+ // (1000 / sampleHz)`. If yes, the worklet body runs and the value
64
+ // updates; if no, the worklet returns silently.
65
+ //
66
+ // Edge cases:
67
+ // - First-ever tick: `lastSampleMs.value` starts at 0; first frame's
68
+ // timestamp will be >> 0 → first tick always fires. Subsequent
69
+ // ticks throttle as expected.
70
+ // - vc v4 timestamp semantics: per the project's worklet-throttle
71
+ // gotcha note, `frame.timestamp` is NOT reliably nanoseconds in
72
+ // vc v4. The hook treats `frame.timestamp` as ALREADY in
73
+ // milliseconds (which is what vc v4 actually delivers; the
74
+ // v0.8.0 StitcherFrame contract documents this). If a future
75
+ // vc version changes the unit, the throttle math here needs
76
+ // re-checking.
77
+ Object.defineProperty(exports, "__esModule", { value: true });
78
+ exports.useThrottledFrameProcessor = useThrottledFrameProcessor;
79
+ const react_native_worklets_core_1 = require("react-native-worklets-core");
80
+ const useFrameProcessor_1 = require("./useFrameProcessor");
81
+ /**
82
+ * Throttled variant of `useFrameProcessor`. See the module
83
+ * docstring for the full use-case mapping; quick version:
84
+ *
85
+ * ```tsx
86
+ * const fp = useThrottledFrameProcessor(
87
+ * (frame) => {
88
+ * 'worklet';
89
+ * // worklet-native OCR / ML / depth processing here
90
+ * },
91
+ * { sampleHz: 2 },
92
+ * [],
93
+ * );
94
+ * return <Camera frameProcessor={fp} ... />;
95
+ * ```
96
+ *
97
+ * @param worklet Host's frame-processor worklet. Must be
98
+ * `'worklet'`-prefixed. Runs at most `sampleHz`
99
+ * times per second.
100
+ * @param options `{ sampleHz }` — clamped to `[0.5, 30]`.
101
+ * @param deps Standard React deps array. Treated the same as
102
+ * `useFrameProcessor`'s deps — when they change the
103
+ * inner worklet is re-bound.
104
+ *
105
+ * @returns A `useFrameProcessor`-shaped processor object, pass it
106
+ * to `<Camera frameProcessor={...}>`.
107
+ */
108
+ function useThrottledFrameProcessor(worklet, options, deps) {
109
+ // Clamp + derive interval. Done outside the worklet so the
110
+ // useSharedValue / useFrameProcessor hooks see stable values.
111
+ const sampleHz = Math.max(0.5, Math.min(30, options.sampleHz));
112
+ const minIntervalMs = 1000 / sampleHz;
113
+ // Monotonic-time gate. Initialised to 0 → first tick always
114
+ // fires (frame.timestamp >> 0).
115
+ const lastSampleMs = (0, react_native_worklets_core_1.useSharedValue)(0);
116
+ // eslint-disable-next-line react-hooks/exhaustive-deps
117
+ return (0, useFrameProcessor_1.useFrameProcessor)((frame) => {
118
+ 'worklet';
119
+ const now = frame.timestamp;
120
+ if (now - lastSampleMs.value < minIntervalMs) {
121
+ return;
122
+ }
123
+ lastSampleMs.value = now;
124
+ worklet(frame);
125
+ },
126
+ // The throttle interval is captured in the worklet closure; if
127
+ // it changes we need to re-bind the worklet so the new
128
+ // `minIntervalMs` takes effect. Same for the host's worklet
129
+ // identity (so deps changes on the host side re-bind too).
130
+ [minIntervalMs, worklet, ...deps]);
131
+ }
132
+ //# sourceMappingURL=useThrottledFrameProcessor.js.map
package/dist/types.d.ts CHANGED
@@ -35,6 +35,93 @@ export interface DeviceMetadata {
35
35
  cameraId: string;
36
36
  flashEnabled: boolean;
37
37
  }
38
+ /**
39
+ * v0.9.0 Layer 3 — one sampled frame delivered by `useFrameStream`
40
+ * to the JS-thread handler.
41
+ *
42
+ * The JPEG file at `jpegPath` is the stream's own copy. Hosts that
43
+ * need long-term retention MUST copy the file synchronously inside
44
+ * the handler — the same path may be overwritten by a subsequent
45
+ * sample (slot reuse — see the hook's docstring for the rotation
46
+ * policy).
47
+ */
48
+ export interface SampledFrame {
49
+ /** Absolute filesystem path to the JPEG. No `file://` prefix. */
50
+ jpegPath: string;
51
+ /**
52
+ * Pose at sample time. `translation` is `undefined` in non-AR
53
+ * mode (gyro provides rotation only; no spatial anchor).
54
+ */
55
+ pose: {
56
+ rotation: [number, number, number, number];
57
+ translation?: [number, number, number];
58
+ };
59
+ /** Frame timestamp (ms; per the v0.8.0 StitcherFrame contract). */
60
+ timestamp: number;
61
+ /** JPEG width / height in pixels. */
62
+ width: number;
63
+ height: number;
64
+ }
65
+ /**
66
+ * v0.9.0 Layer 3 — options for `useFrameStream`.
67
+ *
68
+ * For worklet-native processing without JPEG roundtrip (OCR via
69
+ * Vision/ML Kit, TFLite ML, LiDAR depth), use
70
+ * `useThrottledFrameProcessor` (Layer 2) instead.
71
+ */
72
+ export interface FrameStreamOptions {
73
+ /**
74
+ * Target sampling rate in Hertz. Clamped to `[0.5, 10]`. The
75
+ * Layer 2 throttle gate enforces the rate inside the worklet;
76
+ * ticks too close together are dropped silently.
77
+ *
78
+ * Clamp upper bound (10 Hz) is intentionally lower than Layer 2's
79
+ * (30 Hz) — beyond 10 Hz the per-frame JPEG encode + JS-bridge
80
+ * cost dominates the wall-clock budget. Hosts that need higher
81
+ * rates should be on Layer 2 with their own JPEG encoder call
82
+ * (or no JPEG at all).
83
+ */
84
+ sampleHz: number;
85
+ /**
86
+ * JPEG quality (0-100). Default 75. Clamped silently to
87
+ * `[1, 100]` by the underlying `save_frame_as_jpeg` native plugin.
88
+ */
89
+ quality?: number;
90
+ /**
91
+ * Directory to write JPEG files into. Defaults to a per-app
92
+ * `<cache>/rnis-frame-stream/` subdirectory. The directory is
93
+ * `mkdir -p`'d on first use; hosts that supply an existing
94
+ * absolute path are responsible for its lifecycle.
95
+ */
96
+ outputDir?: string;
97
+ }
98
+ /**
99
+ * v0.9.0 Layer 2 — options for `useThrottledFrameProcessor`.
100
+ *
101
+ * Wraps v0.8.0's `useFrameProcessor` with a monotonic-time throttle
102
+ * gate so the supplied worklet fires at most `sampleHz` times per
103
+ * second. Use for sub-frame-rate worklet-native processing — native
104
+ * OCR (Vision.framework / ML Kit), TFLite ML detection, LiDAR depth
105
+ * processing — where the bbox / depth payloads are small enough to
106
+ * bridge to JS via `runOnJS`.
107
+ *
108
+ * For JS-thread JPEG consumers (file-path OCR libraries, cloud
109
+ * upload, thumbnail UI), use `useFrameStream` (Layer 3) instead.
110
+ */
111
+ export interface ThrottledFrameProcessorOptions {
112
+ /**
113
+ * Target sampling rate in Hertz. Clamped to `[0.5, 30]`. Inside
114
+ * the worklet a monotonic-time gate enforces the rate; ticks too
115
+ * close together are silently dropped.
116
+ *
117
+ * The clamp upper bound (30 Hz) sits at typical AR rates on
118
+ * mid-range Android devices — beyond that, the host should just
119
+ * use `useFrameProcessor` directly (no throttle). The clamp
120
+ * lower bound (0.5 Hz) prevents accidentally-zero-divide values
121
+ * + matches `useFrameStream`'s convention.
122
+ */
123
+ sampleHz: number;
124
+ }
38
125
  export interface CaptureResult {
39
126
  /** Unique device-generated UUID */
40
127
  deviceUuid: string;